Read OSS

Configuration Loading and Expression Evaluation: From HCL to cty.Value

Intermediate

Prerequisites

  • Article 1: Architecture and Codebase Navigation
  • Article 2: Graph Engine and DAG Walk (for understanding when evaluation happens)
  • Basic familiarity with HCL syntax

Configuration Loading and Expression Evaluation: From HCL to cty.Value

Throughout this series, we've treated configuration as a given — something that exists before the graph is built and walked. But the journey from .tf files on disk to the cty.Value results that drive provider calls is itself a sophisticated pipeline. It involves HCL parsing through a virtual filesystem abstraction, assembling a module tree, representing all configuration constructs in a rich Go type model, and — critically — lazily evaluating expressions during graph walks rather than at parse time.

This lazy evaluation is what makes Terraform's dependency system work: a resource's configuration is parsed into an AST immediately, but the values of expressions within that configuration aren't resolved until the graph walk reaches that resource's node, by which time all upstream dependencies have already been evaluated.

The Parser: HCL and Virtual Filesystem

Configuration loading starts with the Parser at internal/configs/parser.go#L20-L30:

type Parser struct {
    fs               afero.Afero
    p                *hclparse.Parser
    allowExperiments bool
}

The afero.Afero virtual filesystem abstraction is a deliberate choice. It enables testing with in-memory filesystems, loading configuration from archives (plan files contain embedded configs), and potentially supporting remote configuration sources — all without changing the parsing logic.

flowchart TD
    FS["Filesystem (afero)"] -->|"ReadFile"| Parser["configs.Parser"]
    Parser -->|".tf files"| HCLNative["hclparse.ParseHCL"]
    Parser -->|".tf.json files"| HCLJSON["hclparse.ParseJSON"]
    HCLNative --> Body["hcl.Body"]
    HCLJSON --> Body
    Body --> Decode["Decode into Go structs"]
    Decode --> File["configs.File"]
    File -->|"multiple files"| Module["configs.Module"]

The parser delegates to hclparse.Parser for the actual HCL parsing. The LoadHCLFile method at lines 59-80 determines the format based on file extension — .json files use HCL's JSON syntax, everything else uses the native HCL syntax. This duality is why Terraform can be configured with either native HCL or JSON — the parser normalizes both into the same hcl.Body representation.

The allowExperiments flag gates access to experimental language features. When building alpha releases, this is set to true, enabling features like for_each on modules (before it was stabilized) or newer additions. The flag is checked at parse time and at usage time, providing defense in depth.

Tip: The parser caches all loaded files internally via hclparse.Parser. This cache is later used to generate source code snippets for diagnostic messages. If you see error messages with source code excerpts, that's the parser cache at work.

The Module Model: A Single Directory's Contents

After parsing individual files, they're merged into a Module at internal/configs/module.go#L19-L63:

type Module struct {
    SourceDir            string
    CoreVersionConstraints []VersionConstraint
    ActiveExperiments    experiments.Set
    Backend              *Backend
    StateStore           *StateStore
    CloudConfig          *CloudConfig
    ProviderConfigs      map[string]*Provider
    ProviderRequirements *RequiredProviders
    Variables            map[string]*Variable
    Locals               map[string]*Local
    Outputs              map[string]*Output
    ModuleCalls          map[string]*ModuleCall
    ManagedResources     map[string]*Resource
    DataResources        map[string]*Resource
    EphemeralResources   map[string]*Resource
    ListResources        map[string]*Resource
    Actions              map[string]*Action
    Moved                []*Moved
    Removed              []*Removed
    Import               []*Import
    Checks               map[string]*Check
    Tests                map[string]*TestFile
}
classDiagram
    class Module {
        +SourceDir string
        +Backend *Backend
        +ProviderConfigs map[string]*Provider
        +Variables map[string]*Variable
        +Locals map[string]*Local
        +Outputs map[string]*Output
        +ModuleCalls map[string]*ModuleCall
        +ManagedResources map[string]*Resource
        +DataResources map[string]*Resource
        +EphemeralResources map[string]*Resource
        +ListResources map[string]*Resource
        +Actions map[string]*Action
        +Moved []*Moved
        +Removed []*Removed
        +Import []*Import
    }
    class Resource {
        +Mode ResourceMode
        +Name string
        +Type string
        +Config hcl.Body
        +Count hcl.Expression
        +ForEach hcl.Expression
        +ProviderConfigRef *ProviderConfigRef
        +DependsOn []hcl.Traversal
    }
    class Variable {
        +Name string
        +Type cty.Type
        +Default cty.Value
        +Validation []*CheckRule
        +Ephemeral bool
    }
    Module --> Resource
    Module --> Variable

A Module represents the contents of a single directory. All .tf files in a directory are merged into one Module — this is why you can split resources across multiple files in the same directory and they all see each other.

Notice that Resource.Config is an hcl.Body, not a decoded Go struct. This is the key to lazy evaluation: the resource's attribute values remain as unevaluated HCL expressions at this stage. They'll be evaluated later, during the graph walk, when we know the values of variables and other resources they reference.

The separation of resources into ManagedResources, DataResources, EphemeralResources, and ListResources reflects the four resource modes Terraform now supports. Each mode has different lifecycle semantics, but they share the same Resource config type.

The Config Tree: Module Hierarchy Assembly

Individual modules are assembled into a tree via the Config type at internal/configs/config.go#L32-L97:

type Config struct {
    Root     *Config
    Parent   *Config
    Path     addrs.Module
    Children map[string]*Config
    Module   *Module
    // source info, version constraints...
}
classDiagram
    class Config {
        +Root *Config
        +Parent *Config
        +Path addrs.Module
        +Children map[string]*Config
        +Module *Module
        +Version *version.Version
    }
    Config --> Config : Root, Parent
    Config --> Config : Children[name]
    Config --> Module : Module

The tree is assembled by config_build.go, which processes module calls in the root module and recursively loads child modules (from local paths, the registry, or other sources). The Root field always points back to the root Config, and Parent points to the calling module. The Path field (of type addrs.Module) provides the static module path — e.g., module.network.module.vpc.

This tree structure is static — it represents the module hierarchy as declared in configuration. During the graph walk, modules may expand into multiple instances due to count and for_each, producing dynamic addrs.ModuleInstance paths. The ModuleExpansionTransformer we saw in Article 2 handles this expansion.

The Address System

The addrs package provides the naming backbone for the entire codebase. Every addressable object in Terraform — providers, modules, resources, variables, outputs — has a corresponding type:

Type Example Usage
addrs.Provider registry.terraform.io/hashicorp/aws Provider identification and FQN
addrs.Module module.network.module.vpc Static module path
addrs.ModuleInstance module.network[0].module.vpc Dynamic module instance with keys
addrs.Resource aws_instance.web Resource within a module
addrs.ResourceInstance aws_instance.web[0] Instance with count/for_each key
addrs.AbsResourceInstance module.network.aws_instance.web[0] Fully qualified instance
addrs.Reference Reference to any referenceable address Used by ReferenceTransformer

The addrs.Provider type at internal/addrs/provider.go#L15 is actually an alias for tfaddr.Provider from the external terraform-registry-address package:

type Provider = tfaddr.Provider

These address types serve as map keys (in state, in the graph node registry), as targeting criteria (for -target flags), and as the basis for reference resolution. They implement String() for human display and support equality comparison, making them suitable as map keys.

Tip: When reading Terraform's source code, pay close attention to whether a function takes addrs.Resource (static, no instance key) or addrs.AbsResourceInstance (fully qualified with module path and instance key). Passing the wrong address level is a common source of bugs.

Lazy Expression Evaluation: lang.Scope and cty

This is the most important concept in Terraform's configuration system: expressions are not evaluated at parse time. The hcl.Expression values stored in resource configs, variable defaults, and output values are evaluated lazily, during the graph walk, through the lang.Scope type.

The evaluation pipeline works like this:

sequenceDiagram
    participant Parser as configs.Parser
    participant Module as configs.Module
    participant Graph as PlanGraphBuilder
    participant Walk as Graph Walk
    participant Node as ResourceNode
    participant Scope as lang.Scope
    participant Provider as Provider

    Parser->>Module: Parse .tf files
    Note over Module: Stores hcl.Expression<br/>(unevaluated AST)
    Module->>Graph: Config input
    Graph->>Walk: Built graph
    Walk->>Node: Execute(evalCtx)
    Node->>Scope: EvalBlock(config, schema)
    Scope->>Scope: Resolve references<br/>(var.x, local.y, aws_vpc.main.id)
    Scope-->>Node: cty.Value (resolved)
    Node->>Provider: PlanResourceChange(resolvedConfig)

When a graph node like NodePlannableResourceInstance executes, it needs to evaluate the resource's configuration to produce the "proposed new state" that gets sent to the provider. It does this through the EvalContext, which provides access to a lang.Scope configured with:

  • Input variable values — resolved from PlanOpts.SetVariables or defaults
  • Local values — computed from their expressions
  • Output values from child modules — available once those modules complete
  • Resource attributes — available once those resources' nodes complete
  • Built-in functionslength(), lookup(), format(), etc.

Variable evaluation at internal/terraform/eval_variable.go and data evaluation at internal/terraform/evaluate_data.go show how this works in practice. The key insight is that the graph's dependency edges guarantee evaluation order: if resource B references resource A, the ReferenceTransformer ensures A's node completes before B's node starts. By the time B evaluates its expressions, A's attributes are already available in the scope.

The cty (pronounced "see-ty") type system is an external library that provides Terraform's dynamic value representation. Unlike Go's static type system, cty.Value can represent any Terraform type — strings, numbers, booleans, lists, maps, objects, and sets — along with special values like cty.UnknownVal (for computed attributes that aren't known until apply) and cty.NullVal (for absent values).

Unknown values are particularly interesting. During planning, many resource attributes are unknown because they depend on values that will only exist after apply (like an EC2 instance ID). Terraform propagates these unknowns through expression evaluation — if you reference aws_instance.web.id in another resource's config, and that ID is unknown, the downstream expression evaluates to unknown too. This is how Terraform can plan resources that depend on not-yet-created infrastructure.

Bringing It All Together

Across seven articles, we've traced Terraform's architecture from the outer shell to the inner core:

  1. Article 1 mapped the codebase: boot sequence, command registration, package layout, and end-to-end request flow.
  2. Article 2 dissected the graph engine: the generic DAG library, parallel walker, and transformer pipeline.
  3. Article 3 followed a resource through the plan-and-apply lifecycle: Context orchestration, node execution, and the hook system.
  4. Article 4 explored the provider plugin system: gRPC communication, protocol versions, and the three-layer abstraction.
  5. Article 5 examined state and backends: the three-tier state model, backend interface hierarchy, and migration flow.
  6. Article 6 covered the CLI layer: command structure, views for human/JSON output, and the diagnostic system.
  7. This article completed the picture with configuration loading and lazy expression evaluation.

The thread connecting all of these is the graph. Configuration is parsed into a module tree that feeds graph construction. The graph's vertices are resource nodes that, when walked, lazily evaluate configuration expressions and make provider calls. The results flow into state and changes. The CLI layer observes execution through hooks and renders output through views. Backends persist state. And the diagnostic system carries rich error information through every layer.

Terraform's architecture is remarkably coherent for a decade-old project. The abstractions — providers.Interface, GraphTransformer, EvalContext, statemgr.Full, tfdiags.Diagnostics — form a set of well-defined contracts that enable the massive ecosystem around Terraform while keeping the core engine's complexity manageable. Understanding these contracts is the key to working effectively with the codebase.