Table of contents
- Knowing the Language
- What Is HCL
- Block Structure
- Comments
- Basic Types
- Collections — list, map, set
- String Interpolation
- Heredoc — Multi-line Strings
- Conditionals — Ternary Operator
- for Expressions — List/Map Transformation
- Splat Operator — Extracting Attributes at Once
- Built-in Functions — Frequently Used in Practice
- The Flow of Expressions and Values
- Common Mistakes
- What’s Next
Knowing the Language
In Part 2, we already wrote .tf files. Words like resource, provider, and data appeared and things worked fine. But as you write more, questions pile up. Why are quotes used sometimes and not others? How do you embed variables inside strings? Can you iterate over a list to create multiple resources?
To answer these questions, we need to go through HCL itself. This part lays out the HCL syntax needed to use Terraform like a map. You don’t need to memorize every expression, but knowing “these tools exist” means you can solve most things with a quick search in practice.
What Is HCL
HCL (HashiCorp Configuration Language) is a configuration DSL created by HashiCorp. It’s shared across HashiCorp tools like Terraform, Vault, Consul, and Nomad. It’s JSON-compatible but supports comments and expressions, making it easier for humans to read.
HCL has only two basic units: blocks and arguments.
# One block
resource "aws_instance" "web" {
# Arguments
ami = "ami-xxx"
instance_type = "t3.micro"
# Nested block
tags = {
Name = "web"
}
}
That’s all there is to it. The entire file is a combination of blocks and arguments, and argument values contain expressions. Expressions are what make HCL “more than just a configuration language.”
Block Structure
A block looks like this.
<TYPE> "<LABEL1>" "<LABEL2>" {
<argument> = <value>
<nested block> { ... }
}
TYPE can be resource, variable, output, provider, module, data, terraform, locals, and others. The number of labels varies by block type.
# 0 labels — terraform, locals
terraform {
required_version = ">= 1.5.0"
}
locals {
environment = "dev"
}
# 1 label — provider, variable, output, module
provider "aws" { region = "ap-northeast-2" }
variable "instance_count" {
type = number
default = 2
}
# 2 labels — resource, data
resource "aws_instance" "web" { ... }
data "aws_ami" "latest" { ... }
resource and data take two labels: “type + name.” The type is the name defined by the provider (aws_instance, aws_s3_bucket), and the name is a logical identifier you assign. This name must be unique within the same module and is used when referencing from other resources.
Comments
There are three comment styles. Any of them works.
# Single-line comment (recommended)
// Single-line comment
/* Multi-line
comment */
In practice, # is the most common. Some use // for consistency with other languages.
Basic Types
The basic types that can go into arguments are simple.
# String
name = "web-server"
# Number
count = 3
price = 1.5
# Boolean
enabled = true
# null — explicitly indicates no value
custom_value = null
If types are confusing, you can explicitly specify them (mainly used in variable blocks). Variables and the type system are covered separately in the next part.
Collections — list, map, set
There are three collection types.
# List (ordered, allows duplicates)
availability_zones = ["ap-northeast-2a", "ap-northeast-2b", "ap-northeast-2c"]
# Map (key-value)
tags = {
Name = "web"
Environment = "prod"
}
# Set (unordered, no duplicates) — no literal syntax, created with functions
unique_ports = toset([80, 443, 80]) # {80, 443}
Access patterns are similar to other languages.
first_az = var.availability_zones[0] # List index
name = var.tags["Name"] # Map key
# or
name = var.tags.Name # Dot notation (only when key follows identifier rules)
Lists and maps are used frequently, so it’s good to get comfortable with them.
String Interpolation
One of the most commonly used features in HCL. Place an expression inside ${...} and its value gets inserted into the string.
variable "environment" {
default = "dev"
}
resource "aws_s3_bucket" "logs" {
bucket = "myapp-logs-${var.environment}" # myapp-logs-dev
}
Any expression can go inside — variables, resource attributes, function calls, and more.
tags = {
Name = "web-${count.index}" # web-0, web-1, ...
CreatedAt = "${formatdate("YYYY-MM-DD", timestamp())}"
BucketName = "${lower(replace(var.project, "_", "-"))}"
}
However, when the entire argument value is a single expression, the convention is to write it directly without interpolation. The old "${var.x}" style is now discouraged.
# Old style
region = "${var.region}"
# Current style (recommended)
region = var.region
Only use ${} when embedding values inside a string.
Heredoc — Multi-line Strings
A syntax that prevents escape hell when writing long strings. Used for multi-line data like IAM policies and user data scripts.
resource "aws_iam_policy" "read_logs" {
name = "read-logs"
policy = <<-EOT
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject"],
"Resource": "arn:aws:s3:::${var.bucket_name}/*"
}
]
}
EOT
}
Starts with <<-EOT and closes with EOT. The marker (EOT) can be whatever you want (EOF, END, POLICY…). The <<- form strips common leading indentation for better readability (<< alone includes indentation in the string).
You can embed JSON policies this way, but the jsonencode() function is safer.
resource "aws_iam_policy" "read_logs" {
name = "read-logs"
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["s3:GetObject"]
Resource = "arn:aws:s3:::${var.bucket_name}/*"
}]
})
}
This approach catches JSON syntax errors (like missing commas) at compile time, so it’s recommended.
Conditionals — Ternary Operator
HCL has no if statement. Instead, it has the ternary operator found in most languages.
<condition> ? <value if true> : <value if false>
Practical usage looks like this.
variable "environment" {
default = "dev"
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux.id
instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"
tags = {
Backup = var.environment == "prod" ? "daily" : "none"
}
}
Frequently used for per-environment branching like “large instances for production, small ones for everything else.” To chain multiple conditions, combine with &&, ||, and !.
high_availability = var.environment == "prod" && var.region != "ap-northeast-2"
If conditionals get too complex, using locals for intermediate variables improves readability.
locals {
is_prod = var.environment == "prod"
use_large = local.is_prod || var.force_large
}
for Expressions — List/Map Transformation
HCL’s for expression is almost identical to Python’s list comprehension. Used when transforming one collection into another.
variable "users" {
default = ["alice", "bob", "carol"]
}
locals {
# List -> List
upper_users = [for u in var.users : upper(u)]
# ["ALICE", "BOB", "CAROL"]
# List -> Map
user_map = {for u in var.users : u => "${u}@example.com"}
# {alice = "alice@example.com", bob = ...}
# Conditional filter
short_names = [for u in var.users : u if length(u) < 5]
# ["alice", "bob"]
}
When iterating over a map, you receive (key, value) together.
variable "tags" {
default = {
env = "dev"
team = "platform"
}
}
locals {
formatted = [for k, v in var.tags : "${k}=${v}"]
# ["env=dev", "team=platform"]
}
When creating multiple resources, combining for expressions with for_each keeps things clean. Resource iteration is revisited in Part 6.
Splat Operator — Extracting Attributes at Once
Used when you want to extract attributes from multiple resources created with count or for_each all at once. * is the splat operator.
resource "aws_instance" "web" {
count = 3
ami = data.aws_ami.amazon_linux.id
instance_type = "t3.micro"
}
# Use splat to get all instances' public_ip as a list
output "all_public_ips" {
value = aws_instance.web[*].public_ip
# ["3.1.1.1", "3.2.2.2", "3.3.3.3"]
}
The same expression using a for expression would be:
value = [for i in aws_instance.web : i.public_ip]
Splat is shorter. For simply extracting the same attribute, splat wins; when processing is needed, for is more appropriate.
Built-in Functions — Frequently Used in Practice
Terraform has over 100 built-in functions. You don’t need to memorize them all — just know “these exist.” Here are a few commonly used ones.
String Functions
lower("Hello") # "hello"
upper("hello") # "HELLO"
title("hello world") # "Hello World"
trim(" hello ", " ") # "hello"
replace("a-b-c", "-", "_") # "a_b_c"
format("hello, %s!", "world") # "hello, world!"
format uses printf-style formatting, so it’s handy to remember.
Collection Functions
length(["a", "b", "c"]) # 3
length("hello") # 5
concat(["a"], ["b"], ["c"]) # ["a", "b", "c"]
merge({a=1}, {b=2}) # {a=1, b=2}
lookup({a=1}, "a", 0) # 1 (default 0)
lookup({a=1}, "b", 0) # 0
contains(["a","b"], "a") # true
keys({a=1, b=2}) # ["a", "b"]
values({a=1, b=2}) # [1, 2]
merge is frequently used for combining maps. A typical pattern is combining base tags with resource-specific tags.
locals {
common_tags = {
ManagedBy = "terraform"
Environment = var.environment
}
}
resource "aws_instance" "web" {
# ...
tags = merge(local.common_tags, { Role = "web" })
}
Encoding/Decoding
jsonencode({ a = 1 }) # "{\"a\":1}"
jsondecode("{\"a\":1}") # {a = 1}
yamlencode({ a = 1 }) # "a: 1\n"
base64encode("hello") # "aGVsbG8="
filebase64("cert.pem") # Read file and base64 encode
Frequently used for EC2 user data and similar cases that require Base64 encoding.
Date/Time
timestamp() # "2026-04-20T04:30:00Z"
formatdate("YYYY-MM-DD", timestamp()) # "2026-04-20"
An important caveat: timestamp() returns a different value on every apply, so using it directly in resource arguments triggers a change detection every time. When using it in tags, either pair it with lifecycle { ignore_changes = [tags["LastUpdated"]] } or avoid using it altogether.
File Reading
file("init.sh") # Read a text file as-is
templatefile("init.sh.tpl", { name = "web" }) # Template rendering
templatefile renders interpolation like ${name} within the file. Useful when separating user data scripts into files.
# init.sh.tpl
#!/bin/bash
echo "Hello, ${name}!" > /tmp/hello.txt
resource "aws_instance" "web" {
# ...
user_data = templatefile("${path.module}/init.sh.tpl", {
name = "terraform"
})
}
The Flow of Expressions and Values
Here’s a diagram showing how the syntax covered so far combines and where everything fits.
flowchart TB
V[variable<br/>External input] --> EX[Expressions]
L[locals<br/>Intermediate calculations] --> EX
D[data source<br/>Query results] --> EX
R2[Other resources<br/>References] --> EX
EX -->|String interpolation<br/>Conditionals<br/>for<br/>Functions| VAL[Final value]
VAL --> R[Resource arguments]
VAL --> O[output]
Most HCL code follows the structure of “processing input values through expressions and feeding them into resource arguments.” The sources of input are variables, locals, data sources, and attributes of other resources. Once this flow becomes familiar, you can read any project’s Terraform code and roughly follow along.
Common Mistakes
A few mistakes that are easy to miss when just following the language description.
- Overusing
${}. When the entire value is a single expression, just write the expression directly (region = var.region). Only use"${var.region}-suffix"when embedding inside a string - Confusing lists and sets.
for_eachrequires a map or set, so passing a list causes an error. Convert withtoset()or create a map with a for expression - Trying to use both
countandfor_eachon one resource. Only one of the two is allowed per resource. Part 6 covers their respective use cases - Using
timestamp()directly on resources. The value changes on every apply, triggering unnecessary change detection - Forgetting to escape
$in heredoc. If you write a Bash variable$VARas-is, Terraform interprets it as interpolation. Use$$VARto produce$VAR
These mistakes stick in memory once you make them. Building a habit of carefully reading error messages is the most helpful thing in the long run.
What’s Next
The syntax covered in this part forms the backbone of HCL. The next part is about the mechanisms that enable “reuse” on top of this syntax. We’ll inject values from outside with variables (variable), extract values with outputs (output), and organize intermediate calculations with locals (locals). Practical topics like the type system, validation, and .tfvars precedence will also be covered.


Loading comments...