linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
Intro
Lessons
Footer
linuxlab-TutorialsPricingAboutPrivacy & cookies
Copyright © 2026 LinuxLab. All rights reserved.
linuxlab.io
Tutorials▾
  • Linux & networking
    File system, processes, TCP/IP, BGP and OSPF
    →
  • Terraform & IaC
    HCL, state, plan/apply on a LocalStack sandbox
    →
  • Git & GitHub
    Object model, plumbing, branching, GitHub Actions
    →
All tutorials →
PricingAboutSign inCreate account
/
  • Introduction
  • Lessons
  • How it works
  • Knowledge base
  • Cheat sheet
  • Capstone
  • Interview prep
Cluster

← все кластеры

HCL: expressions, types, references

HCL semantics and types: type coercion, splat, the dynamic block, conditionals, for-expressions, references, and the graph resolver. What "known after apply" means and why it gets annoying. Junior-level questions plus a couple of traps for mid-level candidates.

6 вопросов · ~20 мин чтения

Questions

На этой странице

  1. 01What types does HCL have? What gets coerced implicitly and what doesn't?
  2. 02What does the splat operator `[*]` do, and how is it different from for?
  3. 03What is a `dynamic` block, and when can't you do without it?
  4. 04What does 'known after apply' mean, and why can't you swap it for a concrete value?
  5. 05How do `try`, `can`, and `coalesce` differ? Where do you use each?
  6. 06Where does a cycle come from, and how does Terraform find it?

#hcl-types-and-coercion

juniorчасто

What types does HCL have? What gets coerced implicitly and what doesn't?

Что отвечать

Primitives: string, number, bool. Collections: list(T), set(T), map(T), tuple([T1,T2,...]), object({k1=T1, k2=T2}). Plus null. Coercion goes one way. A number becomes a string in interpolation (`"port=${var.port}"`), a bool becomes the string `"true"`. The other direction does not happen by itself: to turn the string `"5"` into a number you call `tonumber()`. list and tuple are not the same: a list needs one shared T, a tuple has a fixed length with a different T per position. A set drops ordering and duplicates.

Что хотят услышать

The candidate should: - not mix up list and set: a set is handy for `for_each`, but its order is not guaranteed. A list keeps order, and it keeps duplicates - know the explicit converters `tostring`, `tonumber`, `tobool`, `tolist`, `toset`, `tomap` - explain object vs map: map(string) requires one value type, object allows a different type per key - say that a type constraint on a variable is a shape, not a strict type. `optional()` lets a field be missing from an object (since 1.3+)

Подводные камни

  • ✗ Declaring `variable foo { type = list }` with no parameter: it accepts anything, with no checking. Always write `list(string)`
  • ✗ Comparing `[1,2,3]` with `tolist([1,2,3])`: the first is a tuple, the second a list, the types differ, so `==` treats them as not equal
  • ✗ Using `for_each` with a list: old versions failed, newer ones make you wrap it in `toset()`. Reach for a set from the start

Follow-up

  • ? How does `object({})` differ from `map(any)` in a variable?
  • ? What does `toset([1,1,2])` return?
  • ? When do you need `optional()` in an object type?

Глубина в базе знаний

  • Data types in HCL: string, number, list, map, object
  • HCL: the language you write Terraform in
  • The variable block: input to your configuration
tags: hcl, types

#splat-and-for-expressions

intermediateиногда

What does the splat operator `[*]` do, and how is it different from for?

Что отвечать

Splat is syntactic sugar over a for-expression for collections. `aws_instance.web[*].id` returns a list of every `id` in `aws_instance.web` (when it uses `count` or `for_each`). The equivalent is `[for x in aws_instance.web : x.id]`. Splat only works for direct attribute access. When you need to filter, transform, or build a map, use for. A for-expression builds a list (`[for ...]`), a set (through toset), or a map/object (`{for k,v in m : k => v if cond}`).

Что хотят услышать

A senior should: - point out that the legacy `.*` splat and `[*]` give different results in edge cases with null; in new code, use only `[*]` - show both for forms: the list comprehension `[for]` and the map comprehension `{for}`. A map comprehension needs unique keys, and a duplicate is a plan error - explain when for plus `if` beats splat: filtering (`[for i in ins : i.id if i.public]`), transforming values - mention for with two variables for a map: `for k,v in m`

Подводные камни

  • ✗ Thinking `[*]` works on any collection. It only works on a tuple of objects (the typical case being resources with `count`/`for_each`)
  • ✗ Writing a map comprehension with a key that can repeat. The error shows up only at plan, and the IDE says nothing
  • ✗ Reaching for splat where you need for with a filter. You end up patching splat with postfix tricks, and it reads badly

Follow-up

  • ? When do you need `flatten([for x in xs : x.list])`, and why doesn't plain splat work?
  • ? How do `[*]` and `.*` differ in modern Terraform?
  • ? How do you turn a list of objects into a map keyed by a field?

Глубина в базе знаний

  • References in HCL: how to read aws_s3_bucket.demo.bucket
  • HCL: the language you write Terraform in
  • count and for_each: many resources from one block
tags: hcl, expressions

#dynamic-block-when-needed

intermediateиногда

What is a `dynamic` block, and when can't you do without it?

Что отвечать

`dynamic` builds a nested resource block iteratively. You need it when the number of nested blocks is not known up front: the ingress rules in a security group, the set of lifecycle_rule entries in an S3 bucket. Without dynamic you would either hardcode them or spawn separate resource blocks. The syntax is `dynamic "ingress" { for_each = var.rules ; content { ... } }`, and inside `content` you reach the value through `ingress.value`.

Что хотят услышать

The candidate should: - separate the dynamic block (for a resource's nested blocks) from for_each (for the resource itself). They sit at different levels - note that dynamic hurts readability, so the good habit is to use it only when there are genuinely many rules or the set comes from a variable - mention `iterator`, which renames the variable inside dynamic and helps with nested dynamic blocks - say that one dynamic replaces one block, so two different nested blocks need two dynamic blocks

Подводные камни

  • ✗ Using dynamic for every block 'for consistency'. The code turns into a second layer of syntax for no reason
  • ✗ Giving dynamic an empty collection and expecting no block. The block still appears, just without content, and on some resources that is a provider error
  • ✗ Getting lost in nested dynamic blocks without `iterator`. The `each.value` name collides, and plan complains in a way that is hard to read

Follow-up

  • ? What happens if you pass an empty set to `for_each` on a dynamic block?
  • ? Why do you need `iterator`, and when does the code get ambiguous without it?
  • ? Can you make a dynamic `content` block include itself conditionally?

Глубина в базе знаний

  • HCL: the language you write Terraform in
  • References in HCL: how to read aws_s3_bucket.demo.bucket
  • count and for_each: many resources from one block
tags: hcl, dynamic

#known-after-apply-and-graph

seniorчасто

What does 'known after apply' mean, and why can't you swap it for a concrete value?

Что отвечать

Terraform builds the resource graph from references. A value becomes known only after the resource is actually created in the provider. The id of an EC2 instance, for example, is assigned by AWS itself. At plan time Terraform cannot predict that value, so it prints "(known after apply)". If another expression depends on such an id, it goes unknown in the plan too. This is not a bug. It is an honest "I don't know until apply." It scares people for no reason: you see a 200-line diff where most of the "known after apply" entries are just references reaching deeper.

Что хотят услышать

A senior should: - explain the graph link: each resource is a node, references are edges, and computed attributes resolve as the nodes apply - note that unknown leaks onward: a depends_on pointing at an unknown turns the whole subtree unknown in the plan - say that an unknown in `count` or `for_each` breaks the plan, because Terraform needs known keys for for_each at plan time. The workaround is to move the resource into a separate root or pass the keys in explicitly - mention `terraform plan -refresh-only` to separate real drift from unknown that comes from references

Подводные камни

  • ✗ Running `for_each` over another resource's computed attribute (`aws_subnet.x.id`). The plan fails with 'cannot use unknown value in for_each'
  • ✗ Treating 'known after apply' as an error. It is normal output
  • ✗ Trying to 'pin' an unknown with `lifecycle.ignore_changes`. That is the wrong tool, ignore_changes is only for drift

Follow-up

  • ? Why does `for_each` over `aws_subnet.x[*].id` fail while `count = length(aws_subnet.x)` works?
  • ? What does `-refresh-only` do differently from a normal plan?
  • ? How do you work around `for_each` over an unknown value in plain HCL?

Глубина в базе знаний

  • terraform plan: see what Terraform is about to do
  • [[tf-dag-internals]]
  • terraform graph: resource dependency graph
  • References in HCL: how to read aws_s3_bucket.demo.bucket
tags: hcl, plan, graphbook: mastering.terraform.epub:ch5

#try-can-coalesce

intermediateиногда

How do `try`, `can`, and `coalesce` differ? Where do you use each?

Что отвечать

`try(a, b, c)` returns the first expression that does not raise an error. You need it for optional fields in objects and for reading missing keys from a map. `can(expr)` returns true or false for whether the expression evaluates without error, and it shows up in `validation { condition = can(...) }` on a variable. `coalesce(a, b, c)` returns the first argument that is neither null nor empty, which is what you want for defaults. Do not confuse them: `try` catches evaluation errors, `coalesce` filters out null and emptiness.

Что хотят услышать

The candidate should: - give each function its own case: `try` for missing fields, `can` for validation, `coalesce` for default values - name `coalescelist` as the list counterpart, which returns the first non-empty list - say that `try` wraps a possible error, not a possible null. For null you need `coalesce` or an explicit check - mention that leaning on `try` hides bugs in HCL. If try falls back to the default too often, your data structure is not what you think it is

Подводные камни

  • ✗ Using `try` instead of a `var.x != null` check. try catches any error, including a typo in a field name, so the error gets quietly papered over by the default
  • ✗ Writing `coalesce("", "default")` and expecting 'default'. coalesce does NOT treat `""` as empty; an empty string is valid to it. You need `coalesce(var.x != "" ? var.x : null, "default")`
  • ✗ Using `can` outside a validation block. It is allowed in a normal expression, but it usually signals something: you are catching an error instead of writing correct types

Follow-up

  • ? Why does `coalesce("", "def")` not return "def"? What does it return?
  • ? When is `can` better than `try` with result handling?
  • ? How do you write a validation that checks a variable is not empty and has no whitespace?

Глубина в базе знаний

  • HCL collection functions: length, lookup, merge, concat, flatten
  • HCL: the language you write Terraform in
  • The variable block: input to your configuration
tags: hcl, expressions, functions

#references-and-cycle

seniorредко

Where does a cycle come from, and how does Terraform find it?

Что отвечать

Terraform builds a DAG from resources and references. A cycle is when A depends on B, B depends on C, and C references A. At plan you get the error "Cycle: A, B, C". The usual source is an indirect dependency through `lifecycle.replace_triggered_by` or a `depends_on` that points both ways. Less often it is a genuine circular reference in HCL: `resource "a" { x = b.y }` together with `resource "b" { y = a.z }`. To fix it, `terraform graph` draws the graph so you can see where it closed on itself. People usually break the loop with a data source or by lifting the shared value into a local.

Что хотят услышать

A senior should: - name `terraform graph | dot -Tsvg > graph.svg` as the standard way to spot a cycle by eye - explain where a cycle usually comes from: a hand-written `depends_on` where Terraform already sees an implicit dependency through an attribute, or an indirect dependency through `lifecycle.replace_triggered_by` - say that breaking the loop through a local is a common fix: both resources reference `local.shared_id` instead of each other - mention that module boundaries do not help, since Terraform finds a cycle across a module boundary too

Подводные камни

  • ✗ Adding `depends_on` 'to be safe' where an implicit dependency through an attribute already exists. Instead of helping, you get a cycle
  • ✗ Getting tangled in `replace_triggered_by`, a common cause of sudden cycles after a lifecycle refactor
  • ✗ Expecting the IDE to catch a cycle. It does not, only `terraform plan` does, after it resolves the graph

Follow-up

  • ? How does `terraform graph` help you find a cycle? What is in its output?
  • ? How does `depends_on` differ from an implicit dependency through an attribute?
  • ? What does `replace_triggered_by` do, and why is it often the cause of a cycle?

Глубина в базе знаний

  • References in HCL: how to read aws_s3_bucket.demo.bucket
  • [[tf-dag-internals]]
  • terraform graph: resource dependency graph
  • Resource dependencies: explicit and implicit
tags: hcl, graph, referencesbook: mastering.terraform.epub:ch5
Footer
linuxlab-
Copyright © 2026 LinuxLab. All rights reserved.
Tutorials
Pricing
About
Privacy & cookies