HCL Basics

Understanding HCL’s fundamental constructs: blocks, attributes, expressions, and identifiers.

Before diving into function-hcl specifics, it helps to understand the fundamental building blocks of HCL syntax. This page covers the essentials; for the full language specification see the HCL Native Syntax Specification.

People who have worked with Terraform before should feel free to skip this section.

Blocks

A block is a container that has a type, zero or more labels, and a body enclosed in braces. The body contains attributes and/or nested blocks.

<type> [<label1> <label2> ...] {
  <body: attributes and nested blocks>
}

Examples:

# Block with no labels
locals {
  name = "foo"
}

# Block with one label
resource my-bucket {
  body = { /* ... */ }
}

# Block with two labels
resource my-bucket {
  # 'composite' is the block type, 'status' is a label
  composite status {
    body = { /* ... */ }
  }
}

Unlike attributes, some block types can appear multiple times in the same scope. For example, you can have multiple locals blocks, multiple resource blocks, or multiple composite status blocks.

Attributes

An attribute assigns a value to a name. The value can be a literal, an expression, or a complex object.

# Simple attribute assignments
name    = "my-bucket"
count   = 3
enabled = true

# Object value
tags = {
  env  = "production"
  team = "platform"
}

# Expression value
full_name = "${prefix}-${name}"

Attributes use = for assignment:

body = {
  apiVersion = "s3.aws.upbound.io/v1beta1"
  kind       = "Bucket"
  metadata = {
    name = "my-bucket"
  }
}

An attribute can only be set once in a given scope. Setting the same attribute name twice in the same block is an error.

Expressions and Functions

An expression is anything that produces a value. Expressions appear on the right-hand side of attribute assignments.

Literals

"hello"       # string
42            # number
true          # bool
["a", "b"]   # list
{ x = 1 }    # object

String Interpolation

Strings enclosed in double quotes can contain interpolation sequences using ${ }:

locals {
  greeting = "Hello, ${name}!"
  arn      = "arn:aws:s3:::${bucketName}"
  combined = "${first}-${last}"
}

Any expression is valid inside ${ }, including function calls:

locals {
  upper-name = "PREFIX-${upper(name)}"
  safe-name  = "${try(params.name, "default")}"
}

When a string contains only a single interpolation with no surrounding text, you can drop the quotes entirely. These two are equivalent:

locals {
  # These produce the same result
  a = "${req.composite.metadata.name}"
  b = req.composite.metadata.name
}

Prefer the bare form when no string concatenation is needed.

Operators

CategoryOperators
Arithmetic+, -, *, /, %
Comparison==, !=, <, >, <=, >=
Logical&&, ||, !

Conditional Expressions

locals {
  env = production ? "prod" : "dev"
}

For Expressions

Transform lists and maps inline:

locals {
  upper-names = [for s in names : upper(s)]
  tagged      = { for k, v in items : k => merge(v, { env = "prod" }) }
  filtered    = [for s in names : s if s != ""]
}

Splat Expressions

Shorthand for extracting an attribute from every element in a list:

locals {
  # These are equivalent
  ids-long  = [for r in resources : r.id]
  ids-short = resources[*].id
}

Index and Attribute Access

locals {
  first  = list[0]
  value  = map["key"]
  nested = object.child.field
}

Functions

HCL includes a rich standard library. function-hcl supports all Terraform functions as of v1.5.7, except file I/O functions (file(), templatefile(), etc.) and impure functions (uuid(), timestamp(), etc.). See Built-in Functions for the full list.

Some commonly used ones:

locals {
  merged  = merge(defaults, overrides)
  safe    = try(obj.field, "fallback")
  ok      = can(obj.field)
  items   = join(",", list)
  encoded = base64encode(secret)
  count   = length(names)
}

Identifiers

An identifier is a name used to refer to things – local variables, resource names, block labels, attribute keys, and function names.

HCL identifiers follow these rules:

  • Must start with a letter or underscore
  • Can contain letters, digits, underscores, and dashes
  • Are case-sensitive

Unlike most programming languages where my-bucket would be parsed as my minus bucket, HCL treats dashes as valid identifier characters. This is why you’ll see resource names like:

resource my-s3-bucket {
  # ...
}

locals {
  comp-name = req.composite.metadata.name
}

Both my-s3-bucket and comp-name are single identifiers, not subtraction expressions.

This is especially relevant in function-hcl because Kubernetes resource names use dashes heavily, and crossplane resource names follow the same convention. Being able to write resource my-vpc-subnet { ... } rather than resource my_vpc_subnet { ... } keeps your HCL aligned with the Kubernetes naming conventions it targets.

Object Literals vs Blocks

HCL object literals (used in attribute values) and blocks look similar but are different:

# This is a BLOCK -- it defines structure in the DSL
resource my-bucket {
  # This is an ATTRIBUTE with an object literal VALUE
  body = {
    apiVersion = "s3.aws.upbound.io/v1beta1"
    metadata = {
      name = "foo" # nested object literal
      labels = {
        env = "prod"
      }
    }
  }
}

The key distinction:

  • Blocks define the structure of your function-hcl program (resource, locals, composite status, etc.)
  • Object literals define data values (the Kubernetes manifests, status fields, etc.)

Inside object literals, you can use expressions, string templates, and function calls freely.

Incomplete Values

An expression that references a value not yet available produces an incomplete value. This happens frequently in Crossplane compositions — for example, reading a status field from a resource that hasn’t been created yet:

locals {
  # If the VPC doesn't exist yet, this produces an incomplete value
  vpcId = req.composite.status.vpcId
}

Incomplete values arise from:

  • Accessing an attribute on null (e.g. a resource with no observed status)
  • Indexing into a value that doesn’t exist yet
  • Any expression that transitively depends on an incomplete value

When a block contains an incomplete value, function-hcl defers the entire block rather than raising an error. The block is simply omitted from the output for that reconcile cycle and will be re-evaluated on the next one, once the missing value becomes available.

See Deferred Rendering for a full explanation of this behavior and its safety guarantees.

Summary

ConstructPurposeSyntaxCan repeat?
BlockContains attributes and blockstype [labels] { ... }Depends on type
AttributeAssigns a value to a namename = valueNo (once per scope)
IdentifierNames thingsmy-bucket, compName, region_1N/A
Object literalData value (maps/dicts){ key = value }N/A (it’s a value)
Last modified March 12, 2026: clean up docs, mostly (1ee88e8)