Terraform Resources, Data Sources, Variables, and Outputs
Resource blocks create and manage infrastructure; data blocks only read infrastructure that already exists. That single distinction anchors objective 4 of the Terraform Associate (004) exam. Around those two block types sit the pieces that make a configuration reusable: input variables bring values in, output values expose results, and attribute references such as aws_instance.web.id wire resources together while building Terraform's dependency graph. The exam tests whether you can declare each block correctly, reference it with the right prefix (resources get none, data sources take data., variables take var.), know when data sources are read and what removing a block does, supply variable values through files, flags and environment variables in the correct precedence order, and choose and convert type constraints, from a simple string up to a map of objects with optional attributes. This lesson covers all of it with short, real HCL you can read in one pass.
On this page8 sections
- Resource blocks create and manage infrastructure
- Data sources read infrastructure you do not manage
- Removing blocks and stopping management without destroying
- Attribute references create implicit dependencies
- Input variables parameterize a configuration
- The variable-definition precedence order
- Output values expose results
- Complex types: collections and structural types
- Differentiate resource and data blocks and state what each one, and removing each one, does to real infrastructure
- Explain when Terraform reads a data source and why some reads are deferred to apply
- Reference resource and data source attributes to create implicit cross-resource dependencies
- Declare input variables with type, default, description, nullable and sensitive, and predict what happens when a required value is missing
- Apply the variable-definition precedence order, including how -var and -var-file interact
- Declare output values, handle sensitive outputs, and consume outputs from child modules
- Choose the correct complex type and predict how Terraform converts a value to it, including optional() defaults
Resource blocks create and manage infrastructure
A resource block declares a piece of infrastructure that Terraform creates, updates, and destroys on your behalf. It is the most important block type in the language: everything Terraform manages in state corresponds to a resource. Each block has two labels, the resource type and a local name, and the pair must be unique within a module.
resource "aws_instance" "web" {
ami = "ami-0c02fb55956c7d316"
instance_type = "t3.micro"
tags = {
Name = "web-server"
}
}The resource type's prefix tells Terraform which provider manages it: aws_instance belongs to the aws provider, google_compute_instance to google, and random_pet to random. Elsewhere in the configuration you refer to this resource by the address aws_instance.web — the word resource never appears in the reference.
Arguments inside the block configure the resource; which arguments exist is defined by the provider's schema, not by Terraform core. After an apply, the resource also exposes attributes that the provider computes, such as aws_instance.web.public_ip, which you can read even though you never set them.
Data sources read infrastructure you do not manage
A data block reads information from outside the current configuration — an object managed by another Terraform workspace, created by hand, or published by the provider itself. Data sources never create, modify, or destroy anything; Terraform reads them and exposes the result as read-only attributes.
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
}References to data sources are prefixed with data. — here data.aws_ami.ubuntu.id. Forgetting that prefix (or adding it to a managed resource) is a classic mistake.
When a data source is read
Terraform normally reads a data source during plan, so its values are known in the plan and can be used by resources. The read is deferred to apply when it cannot be done safely at plan time: when one of its arguments is a value that is only known after apply (for example the ID of a VPC this same run will create), or when the data source depends on a managed resource that has changes pending in the plan (directly or through depends_on). In that case the plan shows the data source as "will be read during apply" and every attribute that flows from it shows (known after apply). The result of each read is saved in state, but it is only a cached copy — the next plan reads the data source again.
| resource block | data block | |
|---|---|---|
| Effect on infrastructure | Creates, updates, destroys | Read-only lookup |
| Tracked in state | Yes, as a managed object | Yes, but only the cached read result |
| Reference prefix | None (type.name) | data.type.name |
| Deleting the block from configuration | Plans a destroy of the real object | Drops the cached result from state; no infrastructure change |
| Typical use | Infrastructure you own | Existing AMIs, VPCs, secrets, account IDs |
Removing blocks and stopping management without destroying
Deleting a resource block from configuration tells Terraform the object should no longer exist, so the next plan destroys it. Deleting a data block only removes its cached result from state; nothing real changes, because Terraform never managed that object.
A lifecycle { prevent_destroy = true } setting does not help here: it lives inside the resource block, so it disappears along with the block you deleted and no longer blocks the destroy. To keep the real object but stop Terraform managing it, use one of these instead:
- A
removedblock (Terraform 1.7 and later), which replaces the resource block and is reviewed in a normal plan:
removed {
from = aws_instance.legacy
lifecycle {
destroy = false
}
}terraform state rm aws_instance.legacy, the older imperative way, which edits state directly with no plan. Remove the resource block too, or the next plan will try to create the object again.
Either way, the infrastructure keeps running and simply leaves Terraform state. With destroy = true, a removed block instead destroys the object, the same as deleting the resource block.
Attribute references create implicit dependencies
Referencing one resource's attribute inside another resource is how you wire infrastructure together, and it does double duty: the reference itself tells Terraform the order in which to create things. This is called an implicit dependency, and it is the preferred way to express ordering.
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
resource "aws_subnet" "app" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
subnet_id = aws_subnet.app.id
}Because aws_subnet.app references aws_vpc.main.id, Terraform builds the VPC first, then the subnet, then the instance — with no explicit ordering configuration at all. During a destroy, the graph runs in reverse. The general reference form is <resource_type>.<name>.<attribute>, and you can drill into nested values with further dots and index brackets.
Values that a provider only knows after creation (like an instance ID) are shown as (known after apply) in the plan. Terraform still resolves the graph correctly; it simply defers the concrete value until apply time.
Input variables parameterize a configuration
An input variable is declared with a variable block and referenced as var.<name>. Variables are how the same configuration serves multiple environments without editing the source.
variable "instance_type" {
type = string
description = "EC2 instance type for the web tier"
default = "t3.micro"
}
variable "db_password" {
type = string
sensitive = true
}
variable "environment" {
type = string
default = "dev"
nullable = false
}The arguments to know: type constrains what values are accepted (and converts compatible ones, see the complex-types section); default makes the variable optional; description documents it; sensitive = true redacts the value from plan and apply output; nullable controls whether null is an accepted value; and validation blocks reject bad values with your own error message (custom conditions are covered in the expressions and dependencies lesson).
Required variables and missing values
A variable with no default is required. If nothing supplies a value, Terraform prompts for it in an interactive terminal. When prompting is impossible — -input=false is set, or Terraform runs without a terminal, as in most CI pipelines — the run fails with a "No value for required variable" error instead of guessing.
nullable and explicit null
nullable defaults to true, which means a caller (for example a module block) may pass null explicitly, and that null overrides the default — the variable really is null inside the module. With nullable = false, Terraform treats an explicit null as "not set": the variable falls back to its default, or, if it has no default, a value is required. Use nullable = false when module code should never have to handle null.
The variable-definition precedence order
Terraform loads variable values from several sources, and later sources override earlier ones. From lowest to highest precedence:
- Environment variables named
TF_VAR_<name>(for exampleTF_VAR_instance_type=t3.small) - The
terraform.tfvarsfile, if present - The
terraform.tfvars.jsonfile, if present - Any
*.auto.tfvarsor*.auto.tfvars.jsonfiles, processed in lexical order of their filenames -varand-var-fileoptions on the command line
A default in the variable block sits beneath all of these — it applies only when no source supplies a value. Note the direction: the environment variable is the weakest definition source, and command-line options are the strongest.
-var and -var-file share one precedence level. Terraform applies them in the order they appear on the command line, so if both set the same variable, whichever comes last wins:
terraform apply -var="instance_type=m5.large" -var-file="ci.tfvars"
Here, if ci.tfvars also sets instance_type, its value wins because it comes after the -var. Swap the two options and m5.large wins.
Which flag takes what
-var="name=value"sets one variable directly.-var-file="path/to/file.tfvars"takes a file path, not a name=value pair.terraform.tfvarsand*.auto.tfvarsfiles load automatically; any other file (likeprod.tfvars) is only used when passed with-var-file.-backend-configis aterraform initoption that supplies settings for the state backend. It cannot set input variables.
Output values expose results
An output block exposes a value from a configuration. In the root module, outputs print at the end of terraform apply and are queryable with terraform output; in a child module, outputs are the only values a parent can read, via module.<name>.<output>.
output "instance_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
output "db_connection_string" {
value = "postgres://app:${var.db_password}@${aws_instance.web.private_ip}/app"
sensitive = true
}Sensitive outputs
If an output's value is derived from a sensitive value, the plan errors until you declare sensitive = true on the output. Terraform never marks the output sensitive for you, and the fix is not to remove sensitive from the variable — that would expose the secret everywhere else it is used. Sensitivity only controls display, and it depends on how you look:
| Command | Sensitive output shown as |
|---|---|
terraform plan / terraform apply | Redacted (<sensitive> / (sensitive value)) |
terraform output (list all) | Redacted (<sensitive>) |
terraform show (human-readable) | Redacted |
terraform output NAME | Plain text |
terraform output -raw NAME / terraform output -json | Plain text |
The real value is always in the state file, so anyone who can read state or run those commands can see it.
Module outputs and dependencies
A reference to module.network.vpc_id depends only on the resources that the vpc_id output itself reads, not on everything in the module. If you need to wait for the whole module (including resources no output exposes), add depends_on = [module.network]. A module called with for_each is a map of instances, so you read its outputs per key: module.vpc["staging"].id. Child-module outputs never appear in terraform output on their own; the root module must re-export them with its own output block.
output "staging_vpc_id" {
value = module.vpc["staging"].id
}Outputs commonly feed other systems: a parent module composing child modules, a script parsing terraform output -json, or another Terraform configuration reading them through remote state.
Complex types: collections and structural types
Terraform's type system starts with three primitives — string, number, and bool — and builds two families of complex types on top of them.
Collection types hold many values of one element type:
list(string)— ordered, indexed from zero, duplicates allowed:var.zones[0]set(string)— unordered, unique values, no index access (convert withtolistif you need one)map(string)— key/value pairs accessed asvar.tags["team"]
Structural types hold a fixed shape of possibly different types:
object({...})— named attributes, each with its own typetuple([...])— a fixed-length sequence where each position has its own type
variable "servers" {
type = map(object({
instance_type = string
monitoring = bool
disk_gb = optional(number, 20)
owner = optional(string)
}))
}
variable "endpoint" {
type = tuple([string, number])
default = ["api.example.com", 443]
}optional() attributes and their defaults
optional() lets callers omit an object attribute. optional(number, 20) substitutes 20 when the attribute is omitted or set to null. optional(string) with no default makes the omitted attribute null, so your code must handle null.
any, and why list(any) is not a tuple
any is a placeholder: Terraform infers a concrete type from the value. In list(any) or map(any) it still infers one shared element type, because every element of a collection has the same type. Given ["a", 1, true], Terraform converts all elements to a common type (here, strings: "a", "1", "true"); if no common type exists, the value is rejected. It never keeps a different type per position. When each position genuinely needs its own type, use a tuple.
Automatic type conversion
When a value does not match a type constraint exactly, Terraform converts it if it safely can, and rejects it otherwise:
- A string converts to a number or bool only if it holds a valid representation (
"5"to 5,"true"to true);"five"is an error. Numbers and bools always convert to strings. - A bool never converts to a number, and a number never converts to a bool.
- A list or tuple converts to a set (duplicates are dropped, order is lost) and a set converts to a list.
- A map or object converts to an object type if it has every required attribute; attributes the object type does not declare are discarded.
On the exam, expect to distinguish map from object (uniform element type vs named mixed attributes) and list from tuple (uniform vs per-position types), and to remember that sets are unordered.
Tip. Expect questions that make you differentiate resource and data blocks, including the data. prefix, when a data source is read, and what deleting each kind of block does to real infrastructure (and how to stop managing a resource without destroying it). Variable precedence is heavily tested, usually as a scenario where the same variable is set in several places, including -var and -var-file together. You should also know what happens to a required variable with no value, how nullable treats an explicit null, how outputs are read from child modules (including for_each modules), which commands reveal sensitive outputs, and how Terraform converts values to list, set, map, object and tuple constraints, including optional() defaults.
- resource blocks create and manage infrastructure; data blocks only read existing infrastructure and never change it.
- Resources are referenced as type.name (aws_instance.web); data sources take the data. prefix (data.aws_ami.ubuntu).
- Data sources are read during plan, unless an argument is unknown until apply or they depend on a resource with pending changes; then the read is deferred to apply.
- Deleting a resource block plans a destroy (its prevent_destroy goes with it); a removed block with destroy = false, or terraform state rm, stops management without destroying.
- Referencing another resource's attribute, such as aws_subnet.app.id, creates an implicit dependency that orders operations automatically.
- A variable without a default is required: Terraform prompts, or errors when -input=false or no terminal is available. nullable defaults to true, so an explicit null overrides the default unless nullable = false.
- Precedence, lowest to highest: TF_VAR_, terraform.tfvars, terraform.tfvars.json, *.auto.tfvars (lexical), then -var and -var-file together in command-line order, last wins.
- An output derived from a sensitive value must declare sensitive = true itself; terraform output NAME, -raw and -json still print it in plain text.
- Collections (list, set, map) have one element type, so list(any) unifies mixed elements; tuples and objects keep per-position or per-attribute types, and object conversion drops undeclared attributes.
Frequently asked questions
What is the difference between a resource block and a data block in Terraform?
A resource block declares infrastructure that Terraform creates, updates, and destroys, and it is fully managed in state. A data block performs a read-only lookup of infrastructure that already exists outside the configuration, such as an AMI, a VPC, or an account ID. Data sources are normally read during planning and can never modify real infrastructure; they are referenced with a data. prefix, while managed resources are referenced with no prefix.
When does Terraform read a data source?
Terraform reads a data source during plan, so its values are known before anything changes. If one of its arguments is only known after apply, such as the ID of a resource being created in the same run, or it depends on a managed resource with pending changes, Terraform defers the read to apply and shows dependent values as (known after apply). The result is cached in state and read again on the next plan.
How do I stop Terraform managing a resource without destroying it?
Replace the resource block with a removed block (Terraform 1.7 and later) whose lifecycle sets destroy = false, then apply; the object stays running but leaves state. The older alternative is terraform state rm followed by deleting the block. Simply deleting the resource block plans a destroy, and a prevent_destroy setting inside that block is deleted with it.
Which value wins when a Terraform variable is set in multiple places?
The last-loaded source wins. From lowest to highest precedence: TF_VAR_ environment variables, then terraform.tfvars, then terraform.tfvars.json, then any *.auto.tfvars files in lexical filename order, and finally -var and -var-file command-line options. -var and -var-file share one level and are applied in the order given, so the last one on the command line wins. A default is only used when no source provides a value.
What does nullable = false do on a Terraform variable?
By default (nullable = true) a caller can pass null explicitly, and that null replaces the variable's default. With nullable = false, Terraform treats an explicit null as if the variable were not set, so the default is used instead; if there is no default, a non-null value is required.
Are sensitive outputs hidden everywhere in Terraform?
No. Plan, apply, the plain terraform output listing and human-readable terraform show redact them. But terraform output with the output name, terraform output -raw and terraform output -json print the value in plain text, and the value is stored unencrypted in state. An output derived from a sensitive value must itself declare sensitive = true, or the plan errors.
How does a parent module read values from a child module?
Only through the child module's declared outputs. If a child module defines output "vpc_id", the parent references it as module.network.vpc_id. Resources inside a child module are not directly addressable from the parent, so anything the parent needs must be deliberately exposed as an output.
When should I use an object or tuple instead of a map or list?
Use map(type) or list(type) when every element has the same type; with any, Terraform still picks one shared element type and converts mixed values to it. Use object({...}) when you need named attributes of different types, like an instance_type string alongside a monitoring bool, and tuple([...]) when each position in a fixed-length sequence needs its own type.
Source
This lesson covers the "Terraform configuration" domain of the official Terraform 004 exam guide. Vendors revise their guides — check the source for the current version.
- HashiCorp Terraform Associate certification page — HashiCorp
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.