
Terraform's power extends far beyond basic resource definitions. At its core lies a sophisticated expression language that transforms static configurations into dynamic, reusable, and maintainable infrastructure blueprints. This guide covers expressions, functions, data sources, local values, and meta-arguments—the essential building blocks for professional Infrastructure as Code.
Every value in Terraform has a type. Understanding these types is fundamental to writing correct configurations.
variable "subnet_ids" {
type = list(string)
default = ["subnet-xxxxxxxx", "subnet-yyyyyyyy"]
}
variable "common_tags" {
type = map(string)
default = {
Terraform = "true"
Project = "Alpha"
}
}
variable "config" {
type = object({
name = string
enabled = optional(bool, true)
})
}Strings support dynamic content through interpolation and template directives.
Embed expressions within strings using ${...}:
resource "aws_instance" "web" {
tags = {
Name = "Instance-${var.environment}"
}
}Use %{...} for conditional logic and loops:
locals {
user_list = "%{ for user in var.users ~}${user}\n%{ endfor }"
config = "%{ if var.enable_monitoring }MONITORING_ENABLED%{ else }MONITORING_DISABLED%{ endif }"
}For multi-line strings, use heredoc syntax:
locals {
user_data_script = <<-EOT
#!/bin/bash
apt-get update
apt-get install -y nginx
systemctl start nginx
EOT
}== (equal), != (not equal) — type-strict, so 5 == "5" is false>, >=, <, <= (for numbers)&& (AND), || (OR), ! (NOT)+, -, *, /, % (modulo)Select one of two values based on a boolean condition:
resource "aws_instance" "example" {
instance_type = var.is_production ? "m5.large" : "t2.micro"
}
locals {
backup_window = var.backup_window != null ? var.backup_window : "03:00-04:00"
}Create new collections by iterating over and transforming existing ones.
output "instance_hostnames" {
value = [for name in var.instance_names : "${name}.example.com"]
# Result: ["web.example.com", "app.example.com", "db.example.com"]
}output "user_emails" {
value = {for user in var.users : user => "${user}@example.com"}
# Result: {"alice" = "[email protected]", "bob" = "[email protected]"}
}output "even_numbers_doubled" {
value = [for n in var.numbers : n * 2 if n % 2 == 0]
# Result: [4, 8, 12]
}Use ... to group values into a list when keys might duplicate:
variable "servers" {
type = list(object({ name = string, role = string }))
default = [
{ name = "server1", role = "web" },
{ name = "server2", role = "app" },
{ name = "server3", role = "web" },
]
}
output "servers_by_role" {
value = {for server in var.servers : server.role => server.name...}
# Result: {"web" = ["server1", "server3"], "app" = ["server2"]}
}A shorthand for extracting a list of attributes from a list of objects:
resource "aws_instance" "workers" {
count = 3
ami = var.ami_id
instance_type = "t2.micro"
}
output "worker_ids" {
value = aws_instance.workers[*].id
# Equivalent to: [for inst in aws_instance.workers : inst.id]
}The splat expression [*] is preferred over the legacy .* syntax. If the source is null, the result is an empty list; if it's a single object, it's treated as a single-element list.
Dynamic blocks construct repeatable nested configuration blocks without code duplication:
variable "ingress_rules" {
type = list(object({
port = number
protocol = string
cidr_blocks = list(string)
description = optional(string)
}))
}
resource "aws_security_group" "web_sg" {
name = "web-server-sg"
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ingress.value.cidr_blocks
description = ingress.value.description
}
}
}dynamic "<BLOCK_TYPE>": The nested block type (e.g., "ingress", "setting")for_each: Collection to iterate overiterator (optional): Custom name for the iteration variable (defaults to block type)labels (optional): Unique identifiers for each block instancecontent {}: Defines arguments for each generated blockImportant: Dynamic blocks cannot generate meta-argument blocks like lifecycle. Use for_each on the resource itself for dynamic resource creation instead.
Terraform provides 80+ built-in functions organized by category. User-defined functions are not supported.
FUNCTION_NAME(ARG_1, ARG_2, ...)... to expand lists into arguments: min(var.numbers...)timestamp() and uuid() produce unknown values at plan time, resolved during applyFor mathematical operations and calculations:
locals {
instance_count = ceil(3.7) # Result: 4
floor_value = floor(3.7) # Result: 3
max_disk = max(60, 100, 80) # Result: 100
min_disk = min(60, 100, 80) # Result: 60
power_value = pow(2, 8) # Result: 256
int_value = parseint("FF", 16) # Result: 255
absolute = abs(-42) # Result: 42
}Available numeric functions: abs(), ceil(), floor(), max(), min(), pow(), parseint()
Essential for name generation, formatting, and text manipulation:
locals {
resource_name = lower(format("%s-%s", "MyApp", "Prod"))
# Result: "myapp-prod"
tags_string = join(";", ["owner:team-a", "project:web"])
# Result: "owner:team-a;project:web"
project_code = replace("PROJ-WebApp", "/PROJ-/", "")
# Result: "WebApp"
trimmed = trimspace(" hello ")
# Result: "hello"
is_prod = startswith(var.env, "prod")
}Available string functions: format(), join(), split(), lower(), upper(), title(), substr(), replace(), trimspace(), startswith(), endswith()
Working with lists, maps, and sets:
locals {
zones = ["us-west-2a", "us-west-2b", "us-west-2c"]
zone_count = length(zones) # Result: 3
first_zone = element(zones, 0) # Result: "us-west-2a"
all_zones = concat(zones, ["us-west-2d"])
default_config = {cpus = 2, memory = "4GB"}
override_config = {memory = "8GB", network = "high-speed"}
final_config = merge(default_config, override_config)
# Result: {cpus = 2, memory = "8GB", network = "high-speed"}
config_keys = keys(final_config)
# Result: ["cpus", "memory", "network"] (sorted)
unique_ports = toset([80, 443, 80, 8080])
# Result: [80, 443, 8080] (no duplicates)
}Available collection functions: length(), element(), concat(), flatten(), keys(), values(), lookup(), merge(), toset(), tolist(), tomap(), setproduct()
Convert data between HCL and standard formats (JSON, YAML, Base64):
locals {
iam_policy = {
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "s3:ListBucket"
Resource = "arn:aws:s3:::my-bucket"
}]
}
policy_json = jsonencode(local.iam_policy)
user_data = base64encode("#!/bin/bash\necho hello")
decoded = base64decode(local.user_data)
compressed = base64gzip("large-content-here")
}Available encoding functions: jsonencode(), jsondecode(), yamlencode(), yamldecode(), base64encode(), base64decode(), base64gzip(), urlencode()
Read files from the local filesystem where Terraform executes:
locals {
# Render a template with variables
rendered_script = templatefile("${path.module}/user_data.tftpl", {
server_role = "web-server"
app_version = var.application_version
})
# Read SSH key
ssh_key = trimspace(file(pathexpand("~/.ssh/id_rsa.pub")))
# Check if file exists
has_config = fileexists("${path.module}/custom.conf")
# Read binary file as Base64
binary_data = filebase64("${path.module}/binary.bin")
}Available filesystem functions: file(), templatefile(), pathexpand(), fileexists(), filebase64()
For timestamping, scheduling, and date formatting:
locals {
now = timestamp()
plan_time = plantimestamp() # Terraform 1.5+
formatted_date = formatdate("YYYY-MM-DD", timestamp())
expiry_date = timeadd(timestamp(), "168h") # 7 days
}Available date functions: timestamp(), plantimestamp(), formatdate(), timeadd()
Generate hashes, UUIDs, and cryptographic operations:
locals {
script_hash = sha256(file("${path.module}/configure.sh"))
md5_hash = md5("my-string")
stable_id = uuidv5("dns", "my-service.example.com")
password_hash = bcrypt("my-password", 10)
# For S3 object updates when content changes
file_md5 = filemd5("${path.module}/script.sh")
}
resource "aws_s3_object" "script" {
bucket = var.bucket_name
key = "scripts/configure.sh"
source = "${path.module}/configure.sh"
etag = local.file_md5
}Available hash functions: md5(), sha1(), sha256(), sha512(), filemd5(), filesha1(), filesha256(), filesha512(), uuid(), uuidv5(), bcrypt(), rsadecrypt()
Automate network configurations and subnet calculations:
locals {
vpc_cidr = "10.100.0.0/16"
subnet_cidr = cidrsubnet(local.vpc_cidr, 8, 0)
# Result: "10.100.0.0/24"
host_ip = cidrhost(local.subnet_cidr, 10)
# Result: "10.100.0.10"
subnet_netmask = cidrnetmask(local.subnet_cidr)
# Result: "255.255.255.0"
multi_subnets = cidrsubnets(local.vpc_cidr, 4, 4, 4)
# Result: ["10.100.0.0/20", "10.100.16.0/20", "10.100.32.0/20"]
}Available IP functions: cidrhost(), cidrsubnet(), cidrnetmask(), cidrsubnets()
Explicitly convert values between Terraform types:
locals {
bool_value = tobool("true") # "true" → true
list_value = tolist(toset([1, 2, 2])) # Remove duplicates
num_value = tonumber("42") # "42" → 42
string_value = tostring(123) # 123 → "123"
optional_sgs = try(var.security_groups, [])
is_valid_config = can(var.config.advanced.settings) && var.config.advanced.settings != null
}
variable "security_groups" {
type = list(string)
default = null
}Available conversion functions: tobool(), tolist(), tomap(), tonumber(), toset(), tostring(), try(), can()
Provide information about the execution environment:
locals {
module_path = path.module # Current module's filesystem path
root_path = path.root # Root module's path
cwd_path = path.cwd # Original working directory
workspace_name = terraform.workspace # Current workspace name
# Load workspace-specific config
config_file = "${path.module}/configs/${terraform.workspace}.json"
# Mark sensitive value
secure_key = sensitive(var.api_key)
# Check if value is sensitive (Terraform 1.8+)
is_secret = issensitive(var.password)
}Available context functions: path.module, path.root, path.cwd, terraform.workspace, sensitive(), nonsensitive(), issensitive()
Data sources fetch read-only information from external systems, cloud APIs, local files, or other Terraform states. They are declared with the data keyword and never modify infrastructure.
terraform plandata "<PROVIDER>_<TYPE>" "<LOCAL_NAME>" {
# Configuration arguments (filters/identifiers)
argument_name = expression
# Outputs accessed as: data.<PROVIDER>_<TYPE>.<LOCAL_NAME>.<ATTRIBUTE>
}Managed resources (resource blocks) define infrastructure Terraform creates, reads, updates, and deletes (CRUD operations). Data sources (data blocks) provide read-only information used to configure those resources. An object should be managed by a resource OR referenced by a data source, not both in the same configuration.
The terraform_data resource is an exception—it stores arbitrary values in state without querying external systems.
Fetch Latest AMI
data "aws_ami" "latest_amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*-x86_64-gp2"]
}
}
resource "aws_instance" "app" {
ami = data.aws_ami.latest_amazon_linux.id
instance_type = "t3.micro"
}Reference Existing VPC
data "aws_vpc" "selected" {
id = var.target_vpc_id
}
resource "aws_subnet" "new_subnet" {
vpc_id = data.aws_vpc.selected.id
cidr_block = "10.0.1.0/24"
availability_zone = var.az
}Cross-State Data Sharing
data "terraform_remote_state" "network" {
backend = "remote"
config = {
organization = "my-org"
workspaces = {
name = "prod-network"
}
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.network.outputs.subnet_id
}Fetch Public IP
data "http" "my_public_ip" {
url = "https://api.ipify.org?format=json"
}
locals {
my_ip = jsondecode(data.http.my_public_ip.response_body).ip
}Read Local File
data "local_file" "ssh_key" {
filename = pathexpand("~/.ssh/id_ed25519.pub")
}
resource "aws_key_pair" "deployer" {
key_name = "deployer"
public_key = data.local_file.ssh_key.content
}aws_ami: Find AMI images by various filtersaws_availability_zones: Get available zones in a regionaws_vpc: Reference existing VPCaws_security_group: Find security groupsterraform_remote_state: Access outputs from another Terraform statehttp: Fetch content from HTTP endpointslocal_file: Read files from the local filesystemexternal: Call external programs and parse JSON outputLocal values assign names to expressions, improving code readability and maintainability without exposing them as module inputs or outputs.
locals {
project_prefix = "mycorp"
environment = "dev"
region = "us-east-1"
# Locals referencing other locals
common_name_prefix = "${local.project_prefix}-${local.environment}"
# Complex calculations
instance_count = var.enable_ha ? 3 : 1
# Structured data
common_tags = {
Project = local.project_prefix
Environment = local.environment
ManagedBy = "Terraform"
CostCenter = var.cost_center
}
}
resource "aws_instance" "web" {
count = local.instance_count
instance_type = var.instance_type
tags = merge(
local.common_tags,
{
Name = "${local.common_name_prefix}-${count.index}"
}
)
}| Aspect | Local | Input Variable | Output |
|---|---|---|---|
| Purpose | Internal naming, reduce repetition | Parameterize modules, accept external input | Expose module results, link modules |
| Scope | Internal to the module | Defines module's input API | Exports values from a module |
| Assignment | Expression within module | Set via CLI, env vars, .tfvars | Expression, often resource attribute or local |
| User Input | No; derived internally | Primary input mechanism | Computed, not directly input |
# Without locals—hard to read inline expression
resource "aws_security_group" "example" {
tags = {
Name = "sg-${join("-", [for part in split("-", var.environment): substr(part, 0, 1)])}-${var.application}"
}
}
# With locals—clear and maintainable
locals {
env_prefix = join("-", [for part in split("-", var.environment): substr(part, 0, 1)])
sg_name = "sg-${local.env_prefix}-${var.application}"
}
resource "aws_security_group" "example" {
tags = {
Name = local.sg_name
}
}Meta-arguments are special arguments that modify resource behavior beyond their provider-specific configuration.
count for Resource MultiplicationCreate multiple instances of a resource from a single block:
resource "aws_instance" "servers" {
count = 4
ami = var.ami_id
instance_type = "t2.micro"
tags = {
Name = "Server-${count.index + 1}"
}
}
resource "aws_subnet" "private" {
count = 3
vpc_id = aws_vpc.main.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = element(var.availability_zones, count.index)
}
# Reference: aws_instance.servers[0].id, aws_instance.servers[1].id, etc.
output "instance_ids" {
value = aws_instance.servers[*].id
}Key Points:
count.index: 0-based index of current instancecount.value: Count value (useful with for_each transformations)resource_type.name[index]count for simple cases; use for_each when resources need meaningful identifiersfor_each for Key-Based Resource CreationCreate multiple instances identified by meaningful keys:
variable "subnets" {
type = map(object({
cidr = string
az = string
}))
default = {
public_a = {cidr = "10.0.1.0/24", az = "us-east-1a"}
public_b = {cidr = "10.0.2.0/24", az = "us-east-1b"}
private_a = {cidr = "10.0.10.0/24", az = "us-east-1a"}
}
}
resource "aws_subnet" "example" {
for_each = var.subnets
vpc_id = aws_vpc.main.id
cidr_block = each.value.cidr
availability_zone = each.value.az
tags = {
Name = each.key
}
}
# Reference: aws_subnet.example["public_a"], aws_subnet.example["private_a"], etc.
output "subnet_ids" {
value = {for name, subnet in aws_subnet.example : name => subnet.id}
}Key Points:
each.key: Current iteration keyeach.value: Current iteration valuefor_each for resources needing stable, meaningful identifierscount and for_each simultaneously on same resourcedepends_on for Explicit DependenciesExplicitly declare dependencies Terraform cannot infer:
resource "aws_instance" "app" {
ami = var.ami_id
instance_type = var.instance_type
depends_on = [
aws_security_group.app,
aws_iam_role.app_role
]
iam_instance_profile = aws_iam_instance_profile.app.name
}
module "web_servers" {
source = "./modules/web_servers"
depends_on = [module.vpc, aws_security_group.web]
}Best Practices:
lifecycle for Resource Management StrategyControl creation, modification, and destruction behavior:
resource "aws_instance" "critical" {
ami = var.ami_id
instance_type = "t3.large"
lifecycle {
create_before_destroy = true # Create new before destroying old
prevent_destroy = true # Block terraform destroy
ignore_changes = [tags["LastModified"]] # Ignore specific changes
}
}
resource "aws_autoscaling_group" "example" {
launch_configuration = aws_launch_configuration.app.id
lifecycle {
create_before_destroy = true
ignore_changes = [load_balancers]
}
}Lifecycle Options:
create_before_destroy: Create replacement before destroying old resourceprevent_destroy: Block terraform destroy to prevent accidental deletionignore_changes: Don't trigger updates when specified attributes changereplace_triggered_by: Trigger replacement when other resources changeprovider Meta-Argument for Provider SelectionSpecify non-default provider for a resource:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
provider "aws" "primary" {
region = "us-east-1"
}
provider "aws" "backup" {
region = "us-west-2"
}
resource "aws_s3_bucket" "backup" {
bucket = "my-backup-bucket"
provider = aws.backup
}for_each over count for meaningful resource identifiers[*] for simple attribute extractiontry() and can() for safe optional accessoptional() in variable types instead of lookup() and defaultslocals for readabilitytemplatefile() for script generation rather than inline templatesjsondecode() and jsonencode() for structured data managementfor_each for dynamic resource creationdepends_on with comments explaining the dependency reasonlifecycle conservatively; prefer resource type defaultsfor_each with lookup() for optional variable accessplantimestamp() instead of timestamp() when plan-time value sufficessensitive() functiontry() to handle missing optional data safelycan() before accessing nested attributesany typeoptional() for nullable object attributescan() to validate expected structure before accessvalidation blocks with custom conditionspreconditions to validate inputs before processingpostconditions to verify resource state after provisioningMastering Terraform expressions, functions, data sources, local values, and meta-arguments transforms your IaC practice from simple infrastructure definitions to sophisticated, maintainable, and dynamic configurations. These building blocks enable:
Start by understanding the fundamentals—types, operators, and basic functions—then progressively leverage more advanced features as your infrastructure grows. Remember that clarity and maintainability matter as much as functionality. Well-written Terraform configurations that future team members can understand and modify confidently are the foundation of professional Infrastructure as Code practices.
