Skip to content
intermediate Phase 4 · Variables, Locals, and Expression Logic

The Built-in Function Library

Using string, collection, file, network, and time functions in expressions.

1h
0 problems
Topic Progress 0%

Reading Files at Plan Time: templatefile, fileset, and the Module Boundary

A policy document lives in policies/bucket.json, a startup script in scripts/user-data.sh, and the same user-data template must render differently per environment. Terraform's filesystem functions read and render files during evaluation — with one hard boundary: they see the local filesystem at plan time, and the module is the unit of visibility.

file(path) returns a file's content as a string:

locals {
  user_data_raw = file("${path.module}/scripts/user-data.sh")
}

Two rules decide whether this works. Paths are relative to the current working directory, not the file containing the expression — which is why ${path.module} is almost always correct: it resolves to the directory of the module making the call, so a module used from a repo five directories away still finds its own scripts. (Sibling tokens: path.root is the root module's directory, path.cwd the directory terraform was invoked from. Reach for path.module by default.) The second rule: file and its siblings refuse any path escaping the module directory — ../../secrets/x fails with an error rather than silently reading outside. That is a deliberate guard, and it is also why file cannot be used to read a secrets file "just this once" from a parent directory.

templatefile(path, vars) is file plus rendering:

resource "aws_instance" "web" {
  user_data = templatefile("${path.module}/scripts/user-data.sh", {
    env   = var.environment
    ports = var.app_ports
  })
}

Inside the template, variables arrive as ordinary HCL locals, so the script file can use ${env}, a for loop, and conditionals. Templates have their own directive syntax for control flow: %{ for port in ports ~} … %{ endfor ~} with the ~ trimming whitespace, which keeps generated shell scripts free of blank-line artifacts that break set -e scripts. fileexists(path) returns a boolean — handy for optional includes, where local.overrides = fileexists("${path.module}/overrides.json") ? jsondecode(file("${path.module}/overrides.json")) : {} cleanly models optional configuration. For structured output there is templatestring(...) (Terraform 1.9+) which renders a template held in a value rather than a file — useful when the template itself comes from a data source.

fileset(dir, pattern) enumerates matching files into a set, which pairs with for_each for one-resource-per-file patterns:

locals {
  policy_files = fileset("${path.module}/policies", "*.json")
}

Now the boundary question: what about content that is not on disk? A URL, a config object in SSM, a team's remote policy — do not reach for filesystem functions; they execute at plan time against your machine and CI checkout. The correct tool is a provider data source that reads remote content during the graph evaluation, which means the read is cached in state, refreshable, permissioned through the provider, and visible in the plan:

data "http" "release_manifest" {
  url = "https://config.internal.example/manifest.json"
}
locals { manifest = jsondecode(data.http.release_manifest.response_body) }

The distinction is the one interviewers probe: file() is a build-time read from the working copy and never appears in state; a data source is a graph node with credentials, refresh behavior, and a state entry. The same logic covers secrets in a parameter store — data "aws_ssm_parameter" "license" reads under the provider's IAM identity, refreshes on demand, and carries the parameter's sensitive marking forward, properties no filesystem function can offer. Choose by where the content lives and who must be authorized to read it — and for rendered configuration templates inside your module, Locals for Derived Values shows how the rendered result composes with the rest of your derived values.

Reshaping Maps and Lists at Plan Time: merge, zipmap, keys, flatten

Providers want data in a specific shape; callers want to supply it in a convenient one. The collection functions are the conversion layer, and they run entirely at plan time — no API calls, pure evaluation.

merge assembles maps, later winning. You have seen it in locals; the deeper use is composing provider-shaped maps from fragments:

locals {
  base_tags   = { Project = var.project, ManagedBy = "terraform" }
  region_tags = { Region = var.region }
  tags        = merge(local.base_tags, local.region_tags, var.extra_tags)
}

Merge flattens one level only: nested maps are replaced wholesale, not deep-merged. Teams that expect deep merges write their own two-level merge with a for expression.

zipmap couples two parallel lists. When a caller supplies names = ["alpha", "beta"] and sizes = ["small", "large"], zipmap produces { alpha = "small", beta = "large" }. Parallel lists are fragile — lengths drift silently — so the more robust source is already-paired data:

locals {
  by_name = zipmap(var.instance_names, var.instance_sizes)
  # prefer, when the input allows:
  from_objects = { for i in var.instances : i.name => i.size }
}

keys and values project, sort orders. sort(keys(local.by_name)) gives a deterministic list for a for loop or an output; without sort, map iteration order is not something you should present to humans or feed to tools expecting stable output.

flatten collapses nesting; chunklist splits. Flattening turns [["a"], ["b", "c"]] into ["a", "b", "c"] — the standard move after a for that produced a list of lists, such as expanding per-service port ranges:

locals {
  all_ports = flatten([for svc in var.services : [for p in svc.port_range : p]])
}

chunklist(list, size) does the inverse: it splits a long list into fixed-size groups, the trick for spreading N subnets across exactly 3 availability zones in round-robin order without writing index arithmetic by hand.

A complete composition shows how the pieces chain — turning per-service definitions into the flat, keyed input a security group rule loop wants:

locals {
  ingress_rules = flatten([
    for svc in var.services : [
      for port in svc.port_range : {
        description = svc.name
        from_port   = port
        to_port     = port
        protocol    = "tcp"
      }
    ]
  ])
  ingress_by_key = { for r in local.ingress_rules : "${r.description}-${r.from_port}" => r }
}

resource "aws_security_group_rule" "app" {
  for_each = local.ingress_by_key
  # ...
}

A nested for produces a list of lists, flatten makes one list, and a final for re-keys it into a stable map so for_each manages exactly one rule per port. Every stage is inspectable in terraform console, and the entire pipeline runs at plan time — no generator script, no committed JSON artifact to drift.

A few neighbors complete the shelf: lookup(map, key, default) reads safely, element(list, index) wraps around the end (useful for AZ cycling), concat joins lists, toset de-duplicates, and length counts any collection. Two habits make these functions click: most have an error-raising form and a safe form (map[key] fails on missing keys; lookup defaults), and every one of them operates on values already in the graph — combined with for expressions they replace what people once did with external scripts. Where a value's shape gets genuinely awkward — JSON policies with embedded ARNs — jsonencode inside a local, covered in Locals for Derived Values, beats string surgery every time. The next chapter leaves collections for two families with real traps: network math, and time.

CIDR Math That Never Lies, and Timestamps That Never Settle

Two function families on the same shelf for different reasons: the CIDR functions are the most reliably useful tools in the library, and the time functions are the most reliably dangerous.

Network functions derive addresses from ranges. Given var.vpc_cidr = "10.0.0.0/16":

locals {
  subnet_cidrs = [for i in range(3) : cidrsubnet(var.vpc_cidr, 8, i)]
  # 10.0.0.0/24, 10.0.1.0/24, 10.0.2.0/24

  gateway_ip = cidrhost(local.subnet_cidrs[0], 1)   # 10.0.0.1
  dns_ip     = cidrhost(var.vpc_cidr, 2)            # 10.0.0.2
}

cidrsubnet(range, newbits, netnum) carves a smaller network out of a range by extending the prefix by newbits bits and selecting the netnum-th block — this is how three subnets become three non-overlapping /24s with arithmetic instead of a spreadsheet. cidrhost(range, hostnum) addresses inside a network: offset 0 is the network address, 1 is conventionally the gateway. cidrnetmask(range) returns the plain mask (255.255.0.0) for providers that want that form. All of them error loudly on a malformed CIDR, which doubles as validation of the input — pair them with a validation block if you want the failure before evaluation.

Time functions change every run. timestamp() returns the current UTC time as an RFC3339 string. Call it inside a resource argument and every single plan shows a diff, because the value you applied with is no longer the value timestamp() returns now:

# never do this
backup_window = timestamp()

The trap has a second edge: timestamp() is evaluated during plan, so the value stored in state came from an earlier evaluation than the one in the current diff. The fix is not cleverness with locals — locals re-evaluate on every run too, so wrapping timestamp() in one changes nothing. The value must be captured once by something that persists, meaning a resource attribute written at create time and thereafter read from state:

resource "time_static" "deployed_at" {}
# or: local deploy_time = time_static.deployed_at.rfc3339

The time provider's time_static (or a terraform_data resource with input = timestamp() and no triggers_replace) records the moment on create and returns the same value on every subsequent plan. The pattern that pairs with stored time is derived time — expiry and renewal windows are computed from a value, never stored as one:

data "aws_acm_certificate" "web" {
  domain   = "example.com"
  statuses = ["ISSUED"]
}

locals {
  expires_at    = data.aws_acm_certificate.web.not_after
  renew_after   = timeadd(local.expires_at, "-720h")              # 30 days before expiry
  needs_renewal = timecmp(plantimestamp(), local.renew_after) > 0
}

timeadd offsets an RFC3339 string by a duration, and negative durations work — that is how "thirty days before" reads. timecmp returns -1, 0, or 1 and feeds a conditional, where the branch rules from Conditional Expressions apply. plantimestamp() is the correct input in that comparison: it answers "as of this plan, is renewal due?" — a value only compared, never stored as an argument, so the answer changes only when the calendar actually crosses the boundary.

Know which family you are in: CIDR functions make plans more deterministic; time functions make them less so unless a stored value pins them. A practical audit for the trap: run terraform plan -detailed-exitcode twice in a row without changing anything — exit code 2 means "diff present," and a second consecutive 2 is your signal that some expression, almost always a time function, recomputes on every run.

Quiz

1. Inside a module, `file("scripts/user-data.sh")` fails in CI but works on your laptop. What is the most likely cause?

Question 1 options

2. You need the current UTC time recorded once when a resource is created, with no perpetual diffs afterwards. What works?

Question 2 options

3. With vpc_cidr = "10.0.0.0/16", what does cidrsubnet(var.vpc_cidr, 8, 2) return?

Question 3 options

4. A policy document must be fetched from an internal HTTPS URL at plan time. Which approach is correct?

Question 4 options

Flashcards

Question

Why should file() and templatefile() paths start with ${path.module}, and what do they refuse to read?

Answer

Relative paths resolve against the invocation working directory, which varies between laptops and CI; ${path.module} anchors the path to the module's own directory so it works wherever the module is used. They also refuse paths escaping the module directory (no ../../secrets) — a deliberate boundary.

Question

file() vs a data source for remote content — which reads where, and what does each leave behind?

Answer

file()/templatefile() read the local filesystem at plan/eval time from the working copy and leave nothing in state. A data source (e.g. data "http") reads during graph evaluation with provider credentials, is refreshable, and stores its result in state — the right choice for URLs, SSM parameters, or anything requiring authorization.

Question

What do merge, zipmap, keys, flatten, and chunklist each do, in one line apiece?

Answer

merge: combine maps, later arguments override (one level deep). zipmap: pair two lists into a map. keys/values: project a map's keys or values into a list. flatten: collapse nested lists one or more levels. chunklist: split a list into consecutive fixed-size chunks (e.g. spreading N subnets round-robin across 3 AZs).

Question

How do cidrsubnet and cidrhost differ, given vpc_cidr = 10.0.0.0/16?

Answer

cidrsubnet(var.vpc_cidr, 8, 2) carves a sub-network: 10.0.2.0/24. cidrhost(range, n) addresses a host inside a given network: cidrhost(var.vpc_cidr, 2) is 10.0.0.2. cidrnetmask returns the dotted mask (255.255.0.0) for providers wanting that form.

Question

Why does timestamp() cause a perpetual diff, and what is the correct way to pin it?

Answer

It returns the current time on every evaluation, so each plan compares a new value against the stored one. Putting it in a local does not help — locals re-evaluate too. Pin it by storing it in a create-time resource attribute: time_static, or terraform_data with input = timestamp() and no triggers_replace; plantimestamp() is stable only within a single plan.

Revision Notes

Key Takeaways

  • 1. Filesystem functions read the local working copy at evaluation time and are confined to the module directory — anchor paths with ${path.module}
  • 2. Anything fetched from a URL or a remote store belongs in a data source, which is permissioned, refreshed, and cached in state
  • 3. Collection functions are the plan-time conversion layer between caller-friendly inputs and provider-shaped arguments
  • 4. cidrsubnet and cidrhost make subnet layouts arithmetic — no spreadsheets, and malformed CIDRs fail loudly
  • 5. timestamp() diffs forever; only a persisted create-time value (time_static / terraform_data) pins it, and locals are not persistence

Interview Tips

  • • "file() or a data source?" — decide by content location and authorization: file() reads the checked-out working copy and never hits state; a data source is a graph node with credentials and refresh; mention ${path.module} and the module-boundary refusal
  • • "How would you assign IPs from a CIDR block automatically?" — cidrsubnet inside a for expression over range(n), then cidrhost for gateways/DNS; add that validation of the CIDR shape comes free from the functions erroring
  • • "Why does my plan always show a change on this argument?" — name the timestamp() trap, explain that locals re-evaluate each run, and give the stored-attribute fix (time_static or terraform_data input)
  • • "What does merge do with nested maps?" — one level only; nested objects are replaced wholesale. Teams expecting deep merges must implement them; this shows you have debugged tag drift

Cheat Sheet

Built-in Function Library

Filesystem (read the local working copy; nothing in state):

file("${path.module}/x.sh")                 # content as string; module-boundary enforced
templatefile("${path.module}/x.tftpl", { env = var.environment })
fileset("${path.module}/policies", "*.json")  # set of paths for for_each

Default to ${path.module}; path.root / path.cwd exist but rarely belong in a module.
Remote content → data source (data "http", aws_ssm_parameter), not file().

Collections (pure plan-time evaluation):

Function Role
merge(a, b, ...) combine maps; later wins; one level deep
zipmap(keys_list, values_list) two lists → map
keys(m) / values(m) map → list (sort(keys(m)) for stable output)
flatten([[..], [...]]) collapse nested lists
chunklist(list, n) split into chunks of n
lookup(m, k, d), element, concat, toset, length everyday neighbors

Network: cidrsubnet(range, newbits, netnum) → carved subnet · cidrhost(range, hostnum) → host IP · cidrnetmask(range) → dotted mask. Errors on malformed CIDR = free validation.

Time (churn risk): timestamp() = now (changes every run!) · plantimestamp() = plan timestamp (stable within a run) · timeadd(ts, "24h") · timecmp(a, b). Pin a timestamp with a create-time stored attribute: time_static or terraform_data{ input = timestamp() } — locals do NOT pin it.

Continue Learning

Before You Start

Next Topics

Related Reading