Terraform

Terraform HCL Syntax Basics: Using Block, Attribute, and Expression Correctly

2026-04-19
NicheeLab Editorial Team

Terraform configuration files are written in HCL, and the smallest units are the block, the attribute (argument), and the expression. Once you grasp how these three differ and relate to one another, you can read and write any resource without hesitation.

This article follows the terminology and behavior in the official docs, takes Associate exam patterns into account, and focuses on concrete examples and checkpoints.

HCL Basic Structure: A File Is a Set of Blocks, and Blocks Contain Attributes and Expressions

A Terraform .tf file is a collection of blocks. Each block is made up of a type, zero or more optional labels, and a body ({ ... }) that contains attributes (key = value) and nested blocks. What you can write on the value side is called an expression.

Comments use # or //, and multi-line comments use /* */. Identifiers use letters, digits, and underscores, and may not start with a digit. No special markers are needed at the start or end of a file, and multiple files are combined per directory (the .tf files in the same directory are logically merged).

  • Example block types: resource, data, module, provider, variable, output, locals, terraform, and so on
  • Attributes are key = value pairs in a block body; value is an expression (literal, reference, function call, for expression, conditional, etc.)
  • In HCL2 (Terraform 0.12+), explicit ${} interpolation is unnecessary in most places

HCL syntax relationships (file -> block -> attribute / nested block -> expression)

.tf fileCollection of multiple blocksblocktype + labels + bodyresource "aws_s3_bucket" "this" { ... }attributekey = valuelength = 2nested blockA nested blocklifecycle { ... }attributekey = valuetags = { env = "dev" }expressionliteral / referencefunction / forconditional ...Produces a valueattribute(s)+ expression(s)Same structure as ablock body (recursive)Further nesting allowedexpressionliteral / referencefunction / forconditional ...Produces a valuefile -> block -> attribute / nested block -> expression

Minimal example: a block containing attributes and a nested block

resource "random_pet" "name" {
  length    = 2                # 属性(value は number リテラル)
  prefix    = var.project      # 属性(value は式:変数参照)
  separator = "-"              # 属性(value は string リテラル)
}

output "pet_name" {
  value = random_pet.name.id   # 式:リソース属性参照
}

Block: Type, Labels, and Body, With Optional Nesting

A block is the syntactic unit of HCL. The form is block_type "label1" "label2" { ... } with zero or more labels. The classic case is resource, which takes two labels (type and name). Nested blocks also exist (for example lifecycle, provisioner, and dynamic inside resource).

In official Terraform terminology, key = value pairs inside a block body are called arguments, but they are also commonly written as attributes. Knowing they are synonymous keeps you from being thrown off by the term argument on the exam or in the docs.

  • Common top-level blocks: terraform, provider, resource, data, module, variable, output, locals
  • Whether and how many labels are required depends on the block type (for example variable takes one, terraform takes zero)
  • Duplicate blocks with the same type and labels are basically not allowed (watch out for configuration overrides and conflicts)
ConceptRoleExample / SyntaxPattern
BlockDefines structure (type, labels, body)resource "aws_s3_bucket" "this" { ... }Contains nested blocks and attributes
Attribute (argument)Specifies a property or value of the blockbucket = "example"key = value (value is an expression)
ExpressionComputes or composes a valuevar.name, aws_vpc.main.id, join(",", list)Literal / reference / function / for / conditional

Examples of block types and labels

# ラベルなし
terraform {
  required_version = ">= 1.5.0"
}

# 1 ラベル
variable "project" {
  type        = string
  description = "Project name"
}

# 2 ラベル(type と name)
resource "random_pet" "this" {
  length = 2
}

# 入れ子ブロック(lifecycle)
resource "null_resource" "hook" {
  triggers = { t = timestamp() }
  lifecycle {
    prevent_destroy = true
  }
}

Attribute (Argument): = Assigns a Value, and the Value Is Always an Expression

Attributes are written in a block body as key = value. The value is always an expression and can be a literal (string/number/bool), a collection (list/set/map), a structural value (object/tuple), a reference, a function call, a for expression, or a conditional.

Strings use double quotes, and multi-line strings can use heredocs (<<EOT ... EOT). true and false should be written as booleans without quotes. Implicit type conversion sometimes works, but you should declare type explicitly on variable blocks and normalize values with tonumber and similar functions when needed.

  • String interpolation: write "prefix-${var.name}" rather than "${var.name}" (0.12+)
  • Understand the difference between map and object, and between list and tuple (fixed-type keys/elements vs heterogeneous values)
  • Difference between nested blocks and attributes: structured repeating units are blocks, while single values are attributes by default

Examples of attribute types and forms

variable "env" { type = string }

locals {
  enabled   = true                # bool
  retries   = 3                   # number
  owners    = ["ops", "dev"]      # list(string)
  tags      = { env = var.env }   # map(string)
  config    = { retries = 3, debug = false }  # object
  message   = <<-EOT
Hello ${var.env}
EOT
}

output "tags" {
  value = merge(local.tags, { app = "web" }) # 関数呼び出しも式
}

Expression: Build Values With References, Functions, Conditionals, and for

Expressions are the syntax for producing values. The main forms are references (var.x, local.y, resource.attr), functions (merge, join, coalesce, and so on), the conditional operator (cond ? x : y), for expressions (list/map comprehensions), and the splat operator ([*]).

In 0.12+, ${} is unnecessary in most places. When embedding an expression inside a string, use interpolation like "name-${var.env}". Values that are not determined at plan time (unknown values) become "known after apply", which constrains how they can be used in functions and comparisons.

  • Reference basics: resource.type.name.attribute, data.source.name.attribute, module.mod.output
  • for expressions: lists use [for v in xs : expr]; maps use { for k, v in m : k => expr if cond }
  • Conditional expressions: cond ? a : b (make sure the two branches have matching types)

Concrete expression examples (references, functions, conditionals, for)

variable "names" { type = list(string) }

locals {
  upper_names = [for n in var.names : upper(n)]
  name_map    = { for idx, n in var.names : idx => n }
  pick        = length(var.names) > 0 ? var.names[0] : "default"
  tags        = merge({ env = "dev" }, { app = "api" })
}

output "first_or_default" {
  value = local.pick
}

Patterns That Pay Off in Practice: for_each / count, depends_on, and Input Precedence

Use for_each or count to create multiple resources of the same type. for_each is better when you want to manage by key or keep downstream references stable, while count is fine when index-based identity is enough.

Use depends_on for explicit dependencies, but most dependencies are resolved automatically through attribute references. Input precedence is -var / -var-file > *.auto.tfvars > terraform.tfvars > the TF_VAR_* environment variables > the variable's default (earlier wins).

  • for_each accepts a map or set; reference items with each.key and each.value
  • count.index is 0-based; be careful that later reordering can produce a large diff
  • Catch issues early with terraform fmt for formatting and terraform validate for syntax

Examples of for_each, count, and depends_on

variable "subnets" { type = map(string) } # name => cidr

resource "null_resource" "by_each" {
  for_each = var.subnets
  triggers = { name = each.key, cidr = each.value }
}

resource "null_resource" "by_count" {
  count    = 2
  triggers = { idx = count.index }
}

resource "null_resource" "needs_each" {
  depends_on = [null_resource.by_each]
  triggers   = { ready = true }
}

Pitfalls the Associate Exam Likes to Target

The basic rule is to express types directly: do not write true/false as strings, do not quote numbers. The 0.12+ convention is to use ${} interpolation only inside strings and avoid it where it is unnecessary.

Do not mix up the reference prefixes for resource and data, the evaluation timing of output and locals, or how unknown values (not yet determined at plan time) are handled. The distinction between blocks and attributes (for example lifecycle is a block while tags is a map attribute) is also a frequent exam topic.

  • Correct: enabled = true / Wrong: enabled = "true"
  • Correct: value = aws_vpc.main.id / Wrong: value = aws_vpc.main (always reference down to the attribute)
  • Correct: stabilize keys with for_each / Wrong: lean heavily on order-dependent references via count

Common mistakes and the correct way to write them

# 誤:文字列化された bool
# enabled = "true"
# 正:
enabled = true

# 誤:リソース全体参照
# value = aws_subnet.app
# 正:
value = aws_subnet.app.id

# 誤:不要な補間(0.12+)
# name = "${var.project}"
# 正:
name = var.project

Check Your Understanding

Associate

問題 1

Which HCL element does the following line most accurately represent? availability_zones = length(data.aws_availability_zones.current.names)

  1. It assigns an expression to an attribute (argument); the expression is made up of a data source reference and a function call
  2. It defines a nested block
  3. It declares a provider configuration block
  4. It defines a label

正解: A

It defines an attribute (argument) in the key = value form, and the value side is an expression. The expression references a data source (data.aws_availability_zones.current.names) and computes its length with the length function.

Frequently Asked Questions

Do the terms argument and attribute in the Terraform docs mean the same thing?

They are effectively synonymous. The Terraform spec and docs call the key = value pairs in a block body arguments, while many general explanations call them attributes. On the exam, do not get thrown off when you see the word argument.

Is ${} interpolation still required?

Since 0.12, expressions can be written directly in most places, so it is no longer needed. Use interpolation like "name-${var.env}" when embedding a variable or reference inside a string. When the entire right-hand side of an attribute is an expression, write var.env or local.tags without ${}.

What is the difference between null and an empty string ("")?

null means "no value" and providers or resources treat it as unset or as triggering a default. An empty string is a length-0 string and is an explicit value. Guard with coalesce or try so that conditional expressions or merge results do not unintentionally produce empty strings.

Check what you learned with practice questions

Practice with certification-focused question sets

無料で問題を解いてみる
Author

NicheeLab Editorial Team

NicheeLab editorial team focused on data engineering and cloud certification learning. Content is structured around practical study needs and official exam domains.


Related articles
Terraform

HCL Syntax: Terraform's Configuration Language (2026)

HCL2 fundamentals for Terraform — blocks, attributes, expres...

Terraform

Terraform Authoring & Operations Pro: Complete Guide (2026)

Tactics for the Terraform Pro exam — module authoring, works...

Terraform

Terraform Providers: Plugin Management Fundamentals (2026)

Provider mechanics — required_providers, versions, mirrors, ...

Terraform

Terraform Resource Blocks: Declarative Infra Units (2026)

Resource block fundamentals — addresses, references, common ...

Terraform

Terraform Data Sources: Read-Only External Data (2026)

Data source basics — declaration, refresh behavior, dependen...

Browse all Terraform articles (102)
© 2026 NicheeLab All rights reserved.