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?
2. You need the current UTC time recorded once when a resource is created, with no perpetual diffs afterwards. What works?
3. With vpc_cidr = "10.0.0.0/16", what does cidrsubnet(var.vpc_cidr, 8, 2) return?
4. A policy document must be fetched from an internal HTTPS URL at plan time. Which approach is correct?
Flashcards
Question
Why should file() and templatefile() paths start with ${path.module}, and what do they refuse to read?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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?
Click to reveal answer
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.