CoursesTerraformData sources & dependencies

Data sources & dependencies

Read existing infra; order resources.

Intermediate12 min · lesson 9 of 15

A kitchen has two kinds of ingredients: the ones you cook yourself, and the ones you pull off a shelf that somebody else stocked. Terraform splits your infrastructure the same way. A resource block is something Terraform creates and owns. It goes into state (the file Terraform keeps describing everything it built), it shows up in a plan as a change, and deleting it from your config deletes the real thing. A data block is a lookup. It reads something that already exists, hands you its attributes, and never touches it. Same syntax, opposite direction. One writes. The other only reads.

The classic case for a lookup is a machine image. An AMI (Amazon Machine Image, the disk template a virtual machine boots from) gets republished every few weeks with fresh security patches, and it gets a brand new ID every time. Hard-code ami-0abc123 and every instance you launch boots a snapshot of Ubuntu frozen at whenever you last edited that line. A data source asks the Amazon Web Services (AWS) API a question instead, during the plan. The plan is Terraform's dry run, printed before anything is touched. The question is: which image matches this description, right now?

main.tf
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical's AWS account ID. Never omit this.
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
filter {
name = "architecture"
values = ["x86_64"]
}
}
data "aws_caller_identity" "current" {} # which account am I actually in?
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id # this reference IS the dependency
instance_type = "t3.micro"
}
terminal
$ terraform plan
output
data.aws_caller_identity.current: Reading...
data.aws_ami.ubuntu: Reading...
data.aws_caller_identity.current: Read complete after 0s [id=123456789012]
data.aws_ami.ubuntu: Read complete after 1s [id=ami-0e2c8caa4b6378d8c]
Terraform used the selected providers to generate the following execution plan.
Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# aws_instance.web will be created
+ resource "aws_instance" "web" {
+ ami = "ami-0e2c8caa4b6378d8c"
+ arn = (known after apply)
+ id = (known after apply)
+ instance_type = "t3.micro"
+ private_ip = (known after apply)
+ subnet_id = (known after apply)
+ tags_all = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.

Two lines there repay a slow read. Read complete after 1s [id=ami-...] is the lookup hitting the live AWS API before a single thing is planned. And ami shows a real image ID rather than (known after apply), because the answer came back before the plan was printed. Data sources never show up as + or ~ or -, because there is nothing to create, change or destroy. They do land in state, though, which catches people out.

terminal
$ terraform state list
output
aws_instance.web
data.aws_ami.ubuntu
data.aws_caller_identity.current

The Lookup a Stranger Can Win

That owners line is doing security work, and it is the easiest line in the file to leave out. Anybody with an AWS account can publish a public AMI and name it whatever they like. Drop owners, keep most_recent = true, and your filter is now searching every public image in the region and picking whichever one was created most recently. That last part matters: most_recent sorts on creation date, not on the name. So a stranger who publishes an image called ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-20260801 five minutes ago wins the comparison against a Canonical build from last week. Your next apply boots their disk, in your account, with your instance profile attached, inside your private subnet. The plan looks fine. It shows a valid AMI ID with a completely plausible name. Security researchers named this class of bug whoAMI when they wrote it up in 2025, and they found it in real production configs.

main.tf
# DANGEROUS: no owners filter. "newest public image whose name matches"
# is a race that a stranger is allowed to enter. Newer provider versions
# warn about exactly this pairing, and warnings scroll past.
data "aws_ami" "bad" {
most_recent = true
filter {
name = "name"
values = ["ubuntu/images/*-24.04-*"]
}
}
# SAFE: owners pins the publisher. Use the account ID, or one of the
# aliases "amazon", "self", "aws-marketplace".
data "aws_ami" "good" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
}

Before you trust an image ID that a lookup produced, ask EC2 (Elastic Compute Cloud, the AWS virtual machine service) who published it. The owner account and the Public flag are the two fields that matter. Run this by hand the first time a config lands, and again whenever the ID moves.

terminal
$ aws ec2 describe-images --image-ids ami-0e2c8caa4b6378d8c \
--query 'Images[0].[OwnerId,Name,CreationDate,Public]' --output text
output
099720109477 ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-20260704 2026-07-04T13:41:26.000Z True

most_recent = true carries a second cost that has nothing to do with attackers. Changing ami on an aws_instance forces replacement, so the morning Canonical publishes a new image, your plan turns into -/+ for every instance using that lookup. Fine for cattle sitting behind an autoscaling group (a pool of identical instances the cloud is free to replace at will). Very much not fine at 4pm on a Friday for a stateful box. You can sidestep the fuzzy search completely. Canonical and Amazon both publish current AMI IDs as public SSM (Systems Manager, the AWS service that stores configuration values and manages instances) parameters, which gives you one exact path instead of a wildcard a stranger can match. At the account level, EC2's Allowed AMIs setting restricts every launch in a region to images from a list of providers you name, so even a poisoned lookup fails at the API. Turn it on in audit mode first and read what it would have blocked.

main.tf
data "aws_ssm_parameter" "ubuntu" {
name = "/aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id"
}
resource "aws_instance" "web" {
# .value is marked sensitive by the AWS provider, which would print the AMI
# as (sensitive value) in every plan. nonsensitive() unwraps it so a reviewer
# can actually see which image is about to boot.
ami = nonsensitive(data.aws_ssm_parameter.ubuntu.value)
instance_type = "t3.micro"
}

How Terraform Works Out the Order

You never write the ordering down anywhere. Terraform reads it out of your references. Think of a recipe step that says "fold in the melted butter". Nobody has to tell you the butter gets melted first, because the instruction mentions it and the order falls out. Putting data.aws_ami.ubuntu.id inside aws_instance.web does the same job: the instance mentions the lookup, so the lookup runs first. Every reference in your config becomes an arrow like that, and the config as a whole becomes a DAG (directed acyclic graph, which is a pile of boxes joined by arrows with no loops allowed). Terraform walks that graph, starting anything whose arrows all point at finished work, ten operations at a time by default. That parallelism is why an apply creating thirty unrelated resources does not take thirty times as long as one.

What one terraform plan does with data and dependencies
1build the graph
every reference becomes an arrow
2read the data sources
arguments known, so read now
3defer the rest
arguments unknown, read at apply
4diff the managed resources
state vs config vs reality
5walk it in order
dependencies first, ten at a time
Managed resources get refreshed; data blocks are read fresh on every plan. -refresh=false skips the refresh, not the data reads, so a plan still needs live credentials and network access to every API a data source touches.
terminal
$ terraform graph -type=plan
output
digraph {
compound = "true"
newrank = "true"
subgraph "root" {
"[root] aws_instance.web (expand)" [label = "aws_instance.web", shape = "box"]
"[root] data.aws_ami.ubuntu (expand)" [label = "data.aws_ami.ubuntu", shape = "box"]
"[root] provider[\"registry.terraform.io/hashicorp/aws\"] (close)" [label = "provider[\"registry.terraform.io/hashicorp/aws\"] (close)", shape = "diamond"]
"[root] provider[\"registry.terraform.io/hashicorp/aws\"]" [label = "provider[\"registry.terraform.io/hashicorp/aws\"]", shape = "diamond"]
"[root] root" [label = "root", shape = "circle"]
"[root] aws_instance.web (expand)" -> "[root] data.aws_ami.ubuntu (expand)"
"[root] data.aws_ami.ubuntu (expand)" -> "[root] provider[\"registry.terraform.io/hashicorp/aws\"]"
"[root] provider[\"registry.terraform.io/hashicorp/aws\"] (close)" -> "[root] aws_instance.web (expand)"
"[root] root" -> "[root] provider[\"registry.terraform.io/hashicorp/aws\"] (close)"
}
}

Pipe that into Graphviz, a drawing tool made for exactly this kind of picture (terraform graph | dot -Tsvg > graph.svg), and you get the real shape of a config. It is the fastest way to understand one you did not write. The acyclic half of DAG is enforced, not advisory. If two objects end up referencing each other, Terraform refuses to plan at all rather than pick an order for you.

terminal
$ terraform plan
output
│ Error: Cycle: aws_security_group.db (expand), aws_security_group.app (expand)

Two security groups (virtual firewalls wrapped around your instances) that each allow traffic from the other are the textbook loop. App points at db, db points at app, and neither can go first. The fix is to lift the mutual reference out into its own smaller resource. Keep both groups bare, then add standalone aws_vpc_security_group_ingress_rule resources that point at them. The loop becomes a line and the graph resolves.

When the Read Waits for Apply

A lookup can only run once its own arguments are known. Filter instances by a tag whose value comes out of a resource being created in the same run, and that argument is unknown while Terraform is planning, so the read gets pushed to apply. Adding depends_on to a data block does the same thing on purpose, even when every argument looks perfectly known. Terraform tells you either way, using <= in place of + and naming the reason on the line above.

main.tf
data "aws_instances" "app" {
instance_tags = {
Role = "app"
}
# Force the wait. The instances this counts are created in this same
# run, so reading during plan would return the previous generation.
depends_on = [aws_instance.app]
}
terminal
$ terraform plan
output
Terraform will perform the following actions:
# data.aws_instances.app will be read during apply
# (depends on a resource or a module with changes pending)
<= data "aws_instances" "app" {
+ id = (known after apply)
+ ids = (known after apply)
+ private_ips = (known after apply)
+ instance_tags = {
+ "Role" = "app"
}
}
# aws_instance.app will be created
+ resource "aws_instance" "app" {
+ ami = "ami-0e2c8caa4b6378d8c"
+ id = (known after apply)
+ instance_type = "t3.micro"
+ tags = {
+ "Role" = "app"
}
+ tags_all = {
+ "Role" = "app"
}
}
Plan: 1 to add, 0 to change, 0 to destroy.

That deferral is the right tool for "this lookup is only correct after that resource has settled". It is not free. A read that happens at apply cannot show you what it will return, so everything downstream of it collapses into (known after apply). A plan that is mostly (known after apply) is a plan nobody can meaningfully review, and plan review is your change-control gate. Spend deferrals deliberately, one at a time, for a reason you can say out loud.

A data source cannot see the future
A data block resolves against the real API, not against your plan. Point one at something a resource in the same run is about to create and you get one of two bad days. Either Error: no matching EC2 Subnet found, because nothing matches yet, which is the good outcome because it stops. Or a stale match from the previous generation of that thing, which applies cleanly and quietly wires your new instance into last month's subnet. Read pre-existing infrastructure with data sources. For anything created in the same run, reference the resource attribute directly (aws_subnet.private[0].id, never a lookup), so Terraform orders it for you and the value is exact.

Reading Another Team's State

Splitting infrastructure into separate state files, network in one and applications in another, keeps a bad apply from taking out everything at once. The app stack still needs the network's subnet IDs, though. The terraform_remote_state data source is the built-in answer: point it at the other stack's backend (the remote place its state file lives, usually an S3 bucket, S3 being Simple Storage Service, the AWS object store) and read the outputs that stack publishes. Nobody hard-codes an ID, and when the network team changes subnets the app picks up the current ones on its next plan.

main.tf
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "acme-tf-state"
key = "prod/network/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "web" {
ami = nonsensitive(data.aws_ssm_parameter.ubuntu.value)
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_ids[0]
}

There is a permission model hiding in that block, and it is bigger than it looks. terraform_remote_state does not fetch two outputs. It downloads and parses the entire state file, then picks the outputs out of the result. You asked a colleague for one phone number and they handed you the filing cabinet. Everyone who can run that plan needs read access to the whole object in S3, and a state file holds every attribute of every resource that stack manages, in plaintext. Database passwords. Private keys. The full contents of any secret the other team looked up. Server-side encryption does not save you here, because S3 decrypts on the way out for anyone the policy allows. Marking a value sensitive hides it from command output and does nothing at all to what gets written to state.

terminal
$ cd ../network && terraform state pull | jq -r '.resources[]
| select(.type == "aws_secretsmanager_secret_version")
| .instances[0].attributes.secret_string'
output
{"db_password":"C0rrect-Horse-Battery-Staple","api_key":"sk_live_9f3a..."}

Where the two stacks belong to different teams, or different trust levels, publish a narrow contract instead of handing over the whole file. The producing stack writes the values it means to share into SSM parameters. The consuming stack reads exactly those paths. Now the IAM (Identity and Access Management, the AWS service that decides who is allowed to do what) grant is one prefix of one parameter path, rather than a state file full of somebody else's secrets.

main.tf
# network stack: publish a deliberate, narrow contract
resource "aws_ssm_parameter" "private_subnet_ids" {
name = "/acme/prod/network/private_subnet_ids"
type = "StringList"
value = join(",", aws_subnet.private[*].id)
}
# app stack: read only that path (ssm:GetParameter on /acme/prod/network/*)
data "aws_ssm_parameter" "private_subnet_ids" {
name = "/acme/prod/network/private_subnet_ids"
}
locals {
private_subnet_ids = split(",", nonsensitive(data.aws_ssm_parameter.private_subnet_ids.value))
}

Explicit Ordering With depends_on

References cover nearly all ordering. Not quite all. Sometimes A has to exist before B, and B never mentions a single attribute of A. Nothing in "bake for 20 minutes" mentions preheating the oven, and you would still preheat it. The usual cloud version is an instance whose startup script pulls its config from S3. The instance references the instance profile (the wrapper that hands an IAM role to a virtual machine), but the policy that actually grants the S3 access hangs off the role behind it. No reference connects the instance to that policy, so Terraform is free to launch the instance first, and the script fails with AccessDenied on a boot nobody is watching. depends_on writes the missing arrow by hand.

main.tf
resource "aws_iam_role_policy" "app_s3" {
role = aws_iam_role.app.id
policy = data.aws_iam_policy_document.app_s3.json
}
resource "aws_instance" "app" {
ami = nonsensitive(data.aws_ssm_parameter.ubuntu.value)
instance_type = "t3.micro"
iam_instance_profile = aws_iam_instance_profile.app.name
user_data = file("${path.module}/bootstrap.sh") # pulls config from S3
# The instance references the profile, not the policy on the role behind it.
# Without this line it can boot and run bootstrap.sh before the grant exists.
depends_on = [aws_iam_role_policy.app_s3]
}

Use it only where the dependency is genuinely real and genuinely invisible. Every depends_on you add is an arrow that stops two things running side by side, and a config sprinkled with defensive ones applies serially, turning a two-minute apply into twenty. If you are reaching for one because "it seemed flaky otherwise", go find the missing reference instead. The flakiness is usually a value you should have been passing through.

Data Sources as Guardrails

Lookups are not only for fetching values. aws_caller_identity and aws_region answer a different question: where am I actually pointed right now? A lifecycle precondition turns that answer into a stop sign. Applying production code with a stale development profile still exported in your shell is one of the most common ways to have a genuinely bad afternoon, and six lines prevent it. The check runs during plan, before anything is created, and it fails the run rather than warning about it. The AWS provider ships a blunter version of the same idea in its allowed_account_ids argument, and there is no reason not to set both.

main.tf
variable "expected_account_id" {
type = string
description = "The AWS account this configuration is allowed to touch."
}
resource "aws_s3_bucket" "audit_logs" {
bucket = "acme-prod-audit-logs"
lifecycle {
precondition {
condition = data.aws_caller_identity.current.account_id == var.expected_account_id
error_message = "Wrong AWS account: credentials belong to ${data.aws_caller_identity.current.account_id}, this config targets ${var.expected_account_id}."
}
}
}
terminal
$ terraform plan -var-file=prod.tfvars
output
data.aws_caller_identity.current: Reading...
data.aws_caller_identity.current: Read complete after 0s [id=555566667777]
│ Error: Resource precondition failed
│ on main.tf line 11, in resource "aws_s3_bucket" "audit_logs":
│ 11: condition = data.aws_caller_identity.current.account_id == var.expected_account_id
│ ├────────────────
│ │ data.aws_caller_identity.current.account_id is "555566667777"
│ │ var.expected_account_id is "123456789012"
│ Wrong AWS account: credentials belong to 555566667777, this config targets
│ 123456789012.

A check block goes one step further. It holds its own scoped data source plus assertions, and a failed assertion produces a warning instead of an error, so it reports a problem without blocking a change that has nothing to do with it. A precondition is a deadbolt. A check block is a smoke alarm. That makes checks the right home for continuous verification: is this instance still on the current published image, is the log bucket still versioned, is public access still blocked. Run terraform plan on a schedule and your checks become a monitoring signal living in the same repository as the infrastructure it watches.

checks.tf
check "ami_is_current" {
data "aws_ami" "latest" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
}
assert {
condition = data.aws_ami.latest.id == aws_instance.web.ami
error_message = "web is not running the latest published Ubuntu 24.04 image. Schedule a rebuild."
}
}
terminal
$ terraform plan
output
Plan: 0 to add, 0 to change, 0 to destroy.
│ Warning: Check block assertion failed
│ on checks.tf line 12, in check "ami_is_current":
│ 12: condition = data.aws_ami.latest.id == aws_instance.web.ami
│ ├────────────────
│ │ aws_instance.web.ami is "ami-0a1b2c3d4e5f60718"
│ │ data.aws_ami.latest.id is "ami-0e2c8caa4b6378d8c"
│ web is not running the latest published Ubuntu 24.04 image. Schedule a
│ rebuild.
terraform plan runs other people's code
Two data sources deserve a hard look in any config you did not write. data "external" executes a program on the machine running Terraform, and data "http" makes an outbound web request. Both fire during plan, before any approval gate, which means a pull request that adds one gets code execution on your CI runner (continuous integration: the shared machine that runs checks on every proposed change) holding whatever cloud credentials that runner holds. Read the state file, post it somewhere, done, and the plan output looks unremarkable. List what a config actually pulls in with terraform providers. Treat external or http turning up under a vendored module as something the author has to explain rather than something you merge, and run plans for untrusted branches on an isolated runner with read-only credentials.
terminal
$ terraform providers
output
Providers required by configuration:
.
├── provider[registry.terraform.io/hashicorp/aws] ~> 5.0
└── module.vendor_logging
├── provider[registry.terraform.io/hashicorp/external]
└── provider[registry.terraform.io/hashicorp/http]
Providers required by state:
provider[registry.terraform.io/hashicorp/aws]
Quick check
01Your platform team grants the app pipeline s3:GetObject on prod/network/terraform.tfstate so a terraform_remote_state data source can read two subnet IDs. What have they actually handed over?
Incorrect — Terraform has no way to fetch a single output from a state file. The whole object comes down over the wire and gets parsed locally.
Correct — The provider downloads and parses the whole state, so the grant is all or nothing: database passwords, private keys and secret values included.
Incorrect — terraform_remote_state is read-only and takes no lock. What leaks here is confidentiality, not integrity.
Incorrect — sensitive only hides values in command output. State stores them in plaintext either way.
02A data "aws_ami" block (aws_ami looks up an AMI, the Amazon Machine Image a virtual machine boots from) sets most_recent = true and matches on a name wildcard, but omits the owners argument. Why is that a security problem and not just a style nit?
Correct — owners is optional, which is the trap, and without it the "newest matching name" selection becomes a race an attacker is free to enter (the whoAMI class of bug).
Incorrect — owners is optional, so Terraform plans happily and shows a plausible AMI ID, which is exactly why the problem slips through.
Incorrect — most_recent sorts on creation date rather than name, so a freshly published image wins no matter what it is called.
Incorrect — the cost is not speed but that an untrusted publisher gets to decide which image you boot.
03In one configuration you create aws_subnet.private and also add a data "aws_subnet" that filters on a fixed Name tag ("app-private") to feed subnet_id into a new aws_instance. A subnet carrying that tag already exists from a previous apply. What is the risk on the next apply?
Incorrect — reading a subnet by a static tag creates no dependency on your new resource, so the lookup does not wait for it.
Correct — a data source resolves against the real API rather than your plan, so a stale match applies cleanly and points the instance at the previous generation.
Incorrect — a no-match error is only the lucky outcome, because when a matching object already exists the read succeeds and misconfigures silently.
Incorrect — because a matching subnet already exists the value is known at plan time and looks perfectly valid, so nothing flags it.

One habit makes all of this reviewable. A lookup's answer is invisible in the config, so review the resolved value rather than the filter that produced it. Save the plan to a file and pull out what Terraform actually chose. (jq is a command-line tool for slicing JSON, the machine-readable format Terraform emits here.)

terminal
$ terraform plan -out=tf.plan > /dev/null
$ terraform show -json tf.plan | jq -r '.planned_values.root_module.resources[]
| select(.type == "aws_instance")
| "\(.address) ami=\(.values.ami) subnet=\(.values.subnet_id // "tbd")"'
output
aws_instance.web ami=ami-0e2c8caa4b6378d8c subnet=subnet-0c9f1a2b3d4e5f607

Diff that one line between your branch and main. If the image ID moved, you find out during review, while the change still costs nothing. Wire the same two commands into your pipeline and post the output as a comment on the pull request: reviewers who would never read a full plan will read one line.

Try this

Run terraform plan on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a data source cannot see the future. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related