Declarative Syntax in Terraform
Terraform uses a declarative approach: you describe the end state you want, and Terraform figures out the steps to get there. This is fundamentally different from writing imperative scripts that specify every action in order.
Declarative vs. Imperative: The Core Difference
Imperative (Bash script):
#!/bin/bash# You must specify every step in the right orderaws ec2 create-security-group --group-name my-sg --description "Web tier"aws ec2 authorize-security-group-ingress --group-name my-sg --protocol tcp --port 80 --cidr 0.0.0.0/0aws ec2 run-instances --image-id ami-0abcdef1234567890 --instance-type t3.micro --security-groups my-sg# If the security group already exists — your script fails# If the instance already exists — you create a duplicateDeclarative (Terraform HCL):
# You describe what should exist — Terraform handles the "how"resource "aws_security_group" "web" { name = "my-sg" description = "Web tier"
ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] }}
resource "aws_instance" "web" { ami = "ami-0abcdef1234567890" instance_type = "t3.micro" vpc_security_group_ids = [aws_security_group.web.id]}# Run this 100 times — same result. Terraform knows what already exists.Why Declarative Wins for Infrastructure
| Property | Imperative Scripts | Terraform Declarative |
|---|---|---|
| Idempotency | Manual, error-prone | Built-in |
| State awareness | None | Tracks all managed resources |
| Dependency resolution | Manual ordering | Automatic DAG |
| Drift detection | Not possible | terraform plan shows drift |
| Rollback | Requires reverse script | Re-apply previous state |
Idempotency is the key property: running terraform apply on a configuration that already matches reality makes zero changes. This makes automation safe — you can run it in CI/CD without fear of accidental duplication.
HCL Syntax Deep Dive
HCL (HashiCorp Configuration Language) is designed to be both human-readable and machine-parseable.
Block Types
# Syntax: <block_type> "<type>" "<name>" { ... }
terraform { # Global settings — no type/name required_version = ">= 1.6"}
provider "aws" { # Provider block — type only, no name region = "us-east-1"}
resource "aws_vpc" "main" { # Resource block — type + name cidr_block = "10.0.0.0/16" enable_dns_hostnames = true}
variable "environment" { # Variable block type = string default = "dev"}
output "vpc_id" { # Output block value = aws_vpc.main.id}
locals { # Locals block — computed values common_tags = { Environment = var.environment ManagedBy = "terraform" }}Expressions and References
# Reference another resource's attributeresource "aws_subnet" "public" { vpc_id = aws_vpc.main.id # Reference by resource.type.name.attribute cidr_block = "10.0.1.0/24"}
# Interpolation in stringsresource "aws_s3_bucket" "logs" { bucket = "logs-${var.environment}-${random_id.suffix.hex}"}
# Conditional expressionresource "aws_instance" "app" { instance_type = var.environment == "production" ? "t3.large" : "t3.micro"}
# For expressionslocals { subnet_ids = [for s in aws_subnet.public : s.id]
# For expression with condition prod_instance_ids = [ for inst in aws_instance.app : inst.id if inst.tags["Environment"] == "production" ]}Terraform’s Dependency Graph
One of the most powerful aspects of declarative syntax: Terraform automatically builds a directed acyclic graph (DAG) of resource dependencies and creates them in the correct order — with parallelism where possible.
# Terraform determines this order automatically:# 1. aws_vpc.main (no dependencies)# 2. aws_subnet.public AND aws_security_group.web (both depend on VPC — run in parallel)# 3. aws_instance.app (depends on both subnet and SG)
resource "aws_vpc" "main" { cidr_block = "10.0.0.0/16"}
resource "aws_subnet" "public" { vpc_id = aws_vpc.main.id # Dependency on VPC cidr_block = "10.0.1.0/24"}
resource "aws_security_group" "web" { vpc_id = aws_vpc.main.id # Dependency on VPC # ...}
resource "aws_instance" "app" { subnet_id = aws_subnet.public.id # Dependency on subnet vpc_security_group_ids = [aws_security_group.web.id] # Dependency on SG # ...}You don’t write the ordering logic — you write the relationships, and Terraform builds the execution plan.
Terraform Plan: Seeing the Declarative Diff
The plan command shows what Terraform will do to move from current state to desired state:
Terraform will perform the following actions:
# aws_instance.app will be created + resource "aws_instance" "app" { + ami = "ami-0abcdef1234567890" + instance_type = "t3.micro" + id = (known after apply) + public_ip = (known after apply) }
# aws_instance.old will be destroyed - resource "aws_instance" "old" { - id = "i-1234567890abcdef0" - instance_type = "t3.small" }
Plan: 1 to add, 0 to change, 1 to destroy.The + / - / ~ symbols show add, destroy, and update-in-place. The declarative model makes this diff straightforward to understand and review in pull requests.
HCL File Organization
Terraform loads all .tf files in the current directory. Conventional organization:
project/├── main.tf # Core resources├── variables.tf # All variable declarations├── outputs.tf # All outputs├── versions.tf # terraform{} and required_providers blocks├── locals.tf # Local computed values└── terraform.tfvars # Variable values (don't commit secrets!)This is a convention, not a rule — Terraform merges all .tf files before processing.