HCL Syntax Basics
This page continues from Terraform Core Commands, Terraform Safety Nets, and Terraform Providers Explained. Every example in those pages was written in HCL — this page explains the language itself.
👉 Launch the official Terraform Sandbox — every example below can be typed directly into a main.tf file there.
HCL stands for HashiCorp Configuration Language. It's the language every .tf file is written in. It's built to be easy for humans to read and write, while still being structured enough for Terraform to parse.
1. The Anatomy of a Block
Every piece of Terraform code is a block. You've already been writing them.
resource "aws_s3_bucket" "demo" {
bucket = "my-practice-bucket"
}
| Part | In the example | What it means |
|---|---|---|
| Block type | resource | What kind of thing this is (resource, variable, provider, output...) |
| Labels | "aws_s3_bucket", "demo" | Identify which resource type, and your nickname for it |
| Body | { ... } | Everything inside the curly braces — the actual settings |
resource blocks always have 2 labels (type + name). Other blocks have 1 label (like variable "region" { ... }) or none at all (like provider "aws" { ... }). The block type decides how many labels it needs.
2. Arguments — Key = Value
Inside a block's body, you set arguments. An argument is just name = value.
resource "aws_s3_bucket" "demo" {
bucket = "my-practice-bucket" # argument: bucket = value
}
The block is the form (e.g., "Create an S3 Bucket"). Each argument is one field on that form you're filling in.
3. Comments
HCL supports 3 comment styles:
# This is a comment (most common style)
// This also works, same as #
/*
This is a multi-line comment.
Useful for longer notes.
*/
Stick to # for single-line comments — it's the style you'll see in almost all Terraform code.
4. Data Types
Every value on the right side of = has a type. HCL figures most of these out automatically from how you write them.
| Type | Example | Notes |
|---|---|---|
| string | "us-east-1" | Always in double quotes |
| number | 5, 3.14 | No quotes |
| bool | true, false | No quotes, lowercase |
| list | ["a", "b", "c"] | Ordered, square brackets |
| map | { Name = "demo", Env = "sandbox" } | Key-value pairs, curly braces |
| null | null | Explicitly "no value" |
Example using several types together
resource "aws_instance" "example" {
ami = "ami-0123456789" # string
instance_count = 3 # number
monitoring = true # bool
availability_zones = ["us-east-1a", "us-east-1b"] # list
tags = { # map
Name = "practice-instance"
Environment = "sandbox"
}
}
You saw this tags = { ... } map already in the Safety Nets guide — now you know its official type is a map.
5. String Interpolation — Mixing Text and Values
You'll often need to build a string out of other values. HCL uses ${ ... } for that:
resource "aws_s3_bucket" "demo" {
bucket = "my-bucket-${var.environment}"
}
If var.environment is "dev", the real bucket name becomes "my-bucket-dev".
If the entire value is just one reference (like bucket = "${var.name}"), drop the ${} and write bucket = var.name instead. Only use ${} when you're mixing a reference into a larger string, like the example above.
Multi-line strings (heredoc)
For longer text blocks (like a script or policy document), use <<EOF ... EOF:
resource "aws_instance" "example" {
ami = "ami-0123456789"
user_data = <<-EOF
#!/bin/bash
echo "Hello from Terraform" > /tmp/hello.txt
EOF
}
The - right after << (as in <<-EOF) lets you indent the text to match your code's formatting — Terraform strips the extra leading whitespace automatically.
6. Expressions — Referencing Other Values
An expression is anything that produces a value. The most common expression is a reference to something else in your code.
Referencing a resource's attribute
resource "random_id" "suffix" {
byte_length = 4
}
resource "aws_s3_bucket" "demo" {
bucket = "my-bucket-${random_id.suffix.hex}"
}
The pattern is always: <block_type>.<name>.<attribute>
Referencing a variable
variable "region" {
default = "us-east-1"
}
provider "aws" {
region = var.region
}
Variables are always referenced as var.<name>.
Operators
locals {
total = 2 + 3 # = 5
is_valid = 5 > 3 # = true
full_name = "web" == "web" # = true
combined = "a" + "b" # ❌ error — use string interpolation instead
}
| Operator type | Examples |
|---|---|
| Math | + - * / % |
| Comparison | == != < > <= >= |
| Logic | && (and) || (or) ! (not) |
Conditional expression (a simple "if")
resource "aws_instance" "example" {
instance_type = var.environment == "prod" ? "t3.large" : "t3.micro"
}
Read it as: condition ? value_if_true : value_if_false
"Is environment prod? If yes, use a big instance. If no, use a small one." All in a single line.
7. variable and locals — Named Values
variable — an input you (or someone else) provides
variable "environment" {
description = "Which environment this is"
type = string
default = "sandbox"
}
Used elsewhere as var.environment.
locals — a named value calculated once, reused everywhere
locals {
bucket_name = "my-bucket-${var.environment}"
}
resource "aws_s3_bucket" "demo" {
bucket = local.bucket_name
}
Used elsewhere as local.bucket_name.
A variable is an input — something that can change from outside (like a setting you pass in). A local is just a shortcut you define for yourself, calculated from other values, so you don't repeat the same expression everywhere.
8. Try It Yourself — Putting It All Together
variable "environment" {
type = string
default = "sandbox"
}
locals {
bucket_name = "hcl-practice-${var.environment}"
}
resource "aws_s3_bucket" "demo" {
bucket = local.bucket_name
tags = {
Environment = var.environment
ManagedBy = "terraform"
}
}
output "final_bucket_name" {
value = aws_s3_bucket.demo.bucket
}
terraform init
terraform plan
Plan: 1 to add, 0 to change, 0 to destroy.
terraform apply # type yes
Outputs:
final_bucket_name = "hcl-practice-sandbox"
terraform destroy # type yes, to clean up
Try changing default = "sandbox" to default = "dev", then re-run terraform plan. Watch the bucket name change automatically — that's local.bucket_name and ${var.environment} working together.
9. A Quick Note on JSON
HCL has a JSON-equivalent format (.tf.json) that Terraform can also read — useful when a file is generated by another program instead of written by hand.
{
"resource": {
"aws_s3_bucket": {
"demo": {
"bucket": "my-practice-bucket"
}
}
}
}
You'll rarely write .tf.json by hand. It exists mainly for tools that generate Terraform code automatically. Stick to regular .tf (HCL) files for anything you write yourself.
Quick Reference
# Comment
# Block: type "label1" "label2" { body }
resource "aws_s3_bucket" "demo" { bucket = "x" }
# Data types
"text" # string
42 # number
true # bool
["a", "b"] # list
{ key = "value" } # map
# Interpolation
"prefix-${var.name}"
# Reference
resource_type.name.attribute
var.name
local.name
# Conditional
condition ? value_if_true : value_if_false
Cheat Sheet: Which One Do I Need?
| I want to... | Use this |
|---|---|
| Define a reusable input value | variable "name" { ... } |
| Calculate a value once and reuse it | locals { ... } |
| Mix a value into a larger string | "text-${var.name}" |
| Reference another resource's result | resource_type.name.attribute |
| Write a longer block of text (like a script) | <<-EOF ... EOF |
| Pick between two values based on a condition | condition ? a : b |
Summary
- Every
.tffile is made of blocks: a type, optional labels, and a{ }body. - Inside a block, you set arguments as
name = value. - Values have types — string, number, bool, list, map, null — and HCL infers them automatically.
${ ... }mixes a value into a larger string; drop it when the value stands alone.- Expressions let you reference other resources (
type.name.attribute), variables (var.name), and locals (local.name). variableblocks define inputs;localsblocks define reusable calculated values..tf.jsonis a machine-friendly equivalent of HCL, rarely written by hand.
Q&A