Resources & Data Sources: The Building Blocks

Resources & Data Sources: The Building Blocks
Two Fundamental Block Types — And They Solve Different Problems

Everything you do in Terraform comes down to two block types. Understanding when to use each one — and what happens when you use the wrong one — is fundamental.

A resource is something Terraform creates, owns, and manages throughout its lifecycle. Terraform creates it, tracks it in state, updates it when your config changes, and destroys it when you remove it from config. You have full control.

A data source is something Terraform reads but doesn’t manage. It’s a lookup — a query against your cloud or another system to fetch information about something that already exists. Terraform reads it, uses the values, but never modifies or destroys it.

# Resource — Terraform creates and owns this
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"
}

# Data source — Terraform reads this (already exists independently)
data "aws_ami" "ubuntu" {
  most_recent = true
  owners      = ["099720109477"] # Canonical's AWS account

  filter {
    name   = "name"
    values = ["ubuntu/images/hvm-ssd/ubuntu-*-22.04-amd64-server-*"]
  }
}

Why does the distinction matter? Because if you accidentally use a resource block for something that already exists (created manually or by another team), Terraform either tries to create a duplicate (conflict error) or, worse, assumes it should manage the existing resource’s lifecycle. If you later remove that resource block, Terraform tries to destroy the real thing — even though another team depends on it.

The rule is simple: if you created it and own it, use resource. If it exists independently and you just need its ID, IP, or other attributes, use data.

The Resource Block — Anatomy and Addressing
resource "<PROVIDER>_<TYPE>" "<NAME>" {
  argument1 = value1
  argument2 = value2

  nested_block {
    key = value
  }
}
  • PROVIDER_TYPE — e.g., aws_instance, google_compute_instance. The prefix before the first underscore identifies the provider. This naming convention is how Terraform knows which provider plugin handles each resource.
  • NAME — A local identifier meaningful only within this config. This is NOT the name of the actual cloud resource — that’s usually a separate name or tags.Name argument. aws_instance.web means “the resource I’m calling ‘web'” — it doesn’t set the hostname or instance name in AWS.
  • Arguments — Resource-specific settings. Some are required (like ami for EC2), many are optional with sensible defaults.

To reference this resource’s attributes elsewhere in your config:

# Full reference: <resource_type>.<name>.<attribute>
instance_id = aws_instance.web.id
private_ip  = aws_instance.web.private_ip
arn         = aws_instance.web.arn

Every resource has an address — a unique string path you’ll see in plan output, state commands, and -target flags:

aws_instance.web                    # Simple resource
aws_security_group.web_sg           # Another simple resource
module.vpc.aws_subnet.public        # Resource inside a module
aws_instance.web[0]                 # Resource created with count
aws_instance.web["us-east-1"]       # Resource created with for_each

These addresses are how you reference specific resources when using commands like terraform state show, terraform import, or terraform apply -target.

Data Sources — Querying Existing Infrastructure
data "<PROVIDER>_<TYPE>" "<NAME>" {
  filter_argument = value
}

Data sources are read during the plan phase. They query your cloud API to find existing resources matching your criteria and return their attributes.

Reference a data source with the data. prefix:

data.aws_ami.ubuntu.id
data.aws_vpc.existing.cidr_block
data.aws_caller_identity.current.account_id

Practical pattern — deploying into an existing VPC you don’t manage:

data "aws_vpc" "existing" {
  tags = {
    Environment = "prod"
    Team        = "platform"
  }
}

resource "aws_subnet" "app" {
  vpc_id     = data.aws_vpc.existing.id    # Reference the looked-up VPC
  cidr_block = "10.0.10.0/24"
}

You don’t manage the VPC. You don’t own its lifecycle. You just find it by its tags and use its ID. If the platform team changes the VPC (re-IPs it, adds tags), your data source picks up the new values on the next plan.

This pattern is extremely common in large organizations where different teams own different layers of infrastructure. The networking team manages VPCs. The application team deploys into them using data sources. Neither team touches the other’s resources.

When Data Sources Fail — And How to Handle It

Data sources are evaluated during planning. If the query returns no results — or multiple results when one is expected — the plan fails immediately:

Error: Your query returned no results. Please change your search criteria
and try again.

This failure is by design — better to fail at plan time than to apply infrastructure into a nonexistent VPC. But it means your data source filters must be precise enough to match exactly one result (unless the data source type supports multiple results, like aws_subnets which returns a list).

A subtlety about most_recent = true on AMI lookups: the data source returns different results over time as new AMIs are published. Today it returns ami-abc123. Next month, after Canonical publishes a new Ubuntu image, it returns ami-def456. This means your plans are non-deterministic — the same config produces different plans depending on when you run them.

For production configurations where you need reproducibility, consider hardcoding the AMI ID instead of using a dynamic lookup. Or use the data source in a separate “find the AMI” step and pin the result as a variable.

Meta-Arguments — Built Into Every Resource

These aren’t provider-specific. They’re built into Terraform core and work on any resource block, regardless of provider:

Meta-argumentWhat it solvesWhen you need it
depends_onExplicit dependency when Terraform can’t infer oneIAM policy must exist before EC2 boots, but no attribute reference connects them
countCreate multiple identical copies“I need 3 copies of this instance”
for_eachCreate one copy per map/set element“I need one subnet per availability zone”
providerUse a non-default (aliased) providerMulti-region or multi-account deployments
lifecycleControl create/destroy/update behaviorProtect databases from deletion, ignore auto-scaling changes
resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t2.micro"

  # Explicit dependency — no attribute reference, but ordering matters
  depends_on = [aws_iam_role_policy_attachment.s3_access]

  lifecycle {
    create_before_destroy = true  # Create replacement before destroying old
  }
}

depends_on deserves explanation: Terraform usually infers dependencies automatically from attribute references. If subnet A uses aws_vpc.main.id, Terraform knows to create the VPC first. But sometimes there’s a dependency that isn’t expressed through attributes — like an IAM policy that must be attached before an EC2 instance can access S3, even though the instance doesn’t directly reference the policy attachment. That’s when you use depends_on.

The lifecycle Block — Controlling Resource Behavior
lifecycle {
  create_before_destroy = true        # Create replacement before destroying old
  prevent_destroy       = true        # Block any destroy operation
  ignore_changes        = [ami, tags] # Don't react to external changes
  replace_triggered_by  = [aws_launch_template.app.id]  # Force replace when dependency changes
}

Each argument solves a specific operational problem:

  • create_before_destroy — By default, Terraform destroys the old resource before creating the replacement. With create_before_destroy, it creates the new one first, then destroys the old one. This reduces downtime for resources that need continuous availability.

  • prevent_destroy — Protects critical resources (databases, state buckets) from accidental deletion. Any plan that would destroy this resource fails with an error.

  • ignore_changes — Tells Terraform to ignore changes to specific attributes. Useful when an external process (like auto-scaling or a deployment tool) modifies an attribute that you don’t want Terraform to revert.

  • replace_triggered_by — Forces the resource to be replaced when a referenced resource or attribute changes. Useful for resources that need to be rebuilt when an upstream dependency changes, even if their own config hasn’t changed.

Design Scenario: The Shared Infrastructure Pattern

Context: A platform team manages core networking. Application teams deploy into it without owning the network layer.

# Application team's config

# Look up the VPC managed by the platform team
data "aws_vpc" "platform" {
  tags = { Team = "platform", Environment = "prod" }
}

# Look up subnets by tier
data "aws_subnets" "app_tier" {
  filter {
    name   = "vpc-id"
    values = [data.aws_vpc.platform.id]
  }
  tags = { Tier = "app" }
}

# Deploy into their infrastructure
resource "aws_instance" "app_server" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.medium"
  subnet_id     = data.aws_subnets.app_tier.ids[0]

  tags = {
    Team        = "application"
    Environment = "prod"
  }
}

The application team’s Terraform never touches the VPC or subnet lifecycle. They can’t accidentally delete the network. If the platform team re-IPs the subnets, the data source picks up the changes automatically.

This separation of concerns — resources for what you own, data sources for what you reference — is how large organizations structure their Terraform code. It mirrors real organizational boundaries.

Key Takeaways

The fundamental rule: resource = you own the lifecycle. data = you read, don’t touch. Using the wrong one has real consequences — accidentally managing something you don’t own leads to surprise deletions, conflicts, and cross-team incidents.

Meta-arguments (depends_on, count, for_each, lifecycle, provider) are built into Terraform core and work on every resource type. They’re the levers you use to control how resources are created, ordered, protected, and multiplied. Understanding when each one is needed matters more than memorizing syntax.

Data sources execute during planning, which means they add API calls to every plan. For configs with many data sources, this can slow down planning. Be intentional about what you look up — if a value doesn’t change often, consider passing it as a variable instead of looking it up every time.