Datasource Anti-Pattern #
Data sources are a powerful feature that’s easy to misuse. Incorrect usage produces fragile configurations — plans that fail because the searched resource isn’t found, overly tight coupling between workspaces, or configurations that silently change behavior because the data source returns a different result. This article compiles the anti-patterns most commonly found in production Terraform configurations.
flowchart TD
A["❌ Anti-Pattern"] --> B["Data Source\non every apply"]
A --> C["Hardcoded IDs\ninstead of lookups"]
A --> D["Count depends\non a data source"]
A --> E["Chaining\ndata sources"]
B --> F["Slower plans,\nmore API calls"]
C --> G["Breaks when the\nresource changes"]
D --> H["Destroy-recreate\ncascade"]
E --> I["Sequential\nblocking"]
style A fill:#ef4444,stroke:#dc2626,color:#fffAnti-Pattern 1: Non-Specific Filters #
A data source that can return more than one result is a ticking time bomb — it works fine today, but fails as soon as a new resource matching the same filter appears.
# ANTI-PATTERN: Filter by state only — too broad
data "aws_instance" "app_server" {
filter {
name = "instance-state-name"
values = ["running"]
}
# In dev there might be only 1 running instance
# In production there are 10 — ERROR: Your query returned more than one result
}
# ANTI-PATTERN: No filter at all
data "aws_vpc" "main" {
# No filter or ID — Terraform will error if there's more than 1 VPC
}
# CORRECT: A filter specific enough to return exactly 1 result
data "aws_instance" "app_server" {
filter {
name = "tag:Name"
values = ["app-server-production"]
}
filter {
name = "tag:Environment"
values = [var.environment]
}
filter {
name = "instance-state-name"
values = ["running"]
}
}
Anti-Pattern 2: Data Source as an Import Replacement #
A data source only reads a resource — it doesn’t make Terraform “manage” that resource. Using a data source as a way to “use” a resource that should have been imported is a common misunderstanding.
# ANTI-PATTERN: Using a data source for a resource you want to manage
# The team manages the VPC manually and wants Terraform to manage
# its tags, security groups, etc. But they use a data source:
data "aws_vpc" "main" {
id = "vpc-0abcdef1234567890"
}
# Then tries to "update" the VPC via a different resource:
resource "aws_vpc_ipv4_cidr_block_association" "secondary" {
vpc_id = data.aws_vpc.main.id # Reference to the VPC via a data source
cidr_block = "10.1.0.0/16"
}
# Problem: Terraform doesn't manage the VPC itself
# If the VPC is deleted manually, the plan will error
# Tags, DNS settings, etc. can't be managed via a data source
# CORRECT: Import the VPC into Terraform state and manage it as a resource
# terraform import aws_vpc.main vpc-0abcdef1234567890
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
# Now Terraform fully manages this VPC
}
Anti-Pattern 3: Overusing terraform_remote_state #
terraform_remote_state creates direct coupling between two workspaces — a change in the source workspace can break the workspace reading it.
# ANTI-PATTERN: Every workspace reads state from many other workspaces
# the "app" workspace reads from 5 different workspaces
data "terraform_remote_state" "networking" { ... }
data "terraform_remote_state" "security" { ... }
data "terraform_remote_state" "dns" { ... }
data "terraform_remote_state" "certificates" { ... }
data "terraform_remote_state" "iam" { ... }
# Problems:
# - The "app" workspace can't run if any of these 5 workspaces
# has never been applied (state doesn't exist)
# - Any output change in any workspace can break "app"
# - Hard to test in isolation
# CORRECT: Use input variables for decoupling
# The app workspace receives the needed values via variables
variable "vpc_id" {
description = "VPC ID from the networking workspace"
type = string
}
variable "private_subnet_ids" {
description = "List of private subnet IDs from the networking workspace"
type = list(string)
}
# Values are passed explicitly at apply — no dependency on another workspace's state
# terraform apply -var="vpc_id=vpc-abc" -var-file="networking-outputs.tfvars"
Anti-Pattern 4: Data Sources for Values That Should Be Variables #
Using a data source to get a value that could actually be passed as a variable makes the configuration depend on a cloud condition that might change.
# ANTI-PATTERN: Query the VPC to get its CIDR, then use it for calculations
data "aws_vpc" "main" {
id = var.vpc_id
}
# Calculate subnet CIDRs from the queried VPC CIDR
resource "aws_subnet" "app" {
cidr_block = cidrsubnet(data.aws_vpc.main.cidr_block, 8, 1)
# Problem: if the VPC's CIDR block changes, the subnet calculation also changes
# An unexpected VPC change could cause the subnet to be replaced
}
# CORRECT: Pass the needed value as an explicit variable
variable "vpc_cidr" {
description = "VPC CIDR block"
type = string
}
resource "aws_subnet" "app" {
cidr_block = cidrsubnet(var.vpc_cidr, 8, 1)
# An explicit value — only changes if the variable changes
}
Anti-Pattern 5: Data Sources Without Error Handling #
A data source that doesn’t find a resource will error at plan time. Without proper handling, this can block the entire pipeline.
# ANTI-PATTERN: A data source that may not exist in every environment
data "aws_route53_zone" "main" {
name = "myapp.com"
# The dev environment may not have this hosted zone
# → Plans in dev always error
}
# CORRECT: Use count or a conditional for data sources that may not exist
variable "create_dns_records" {
type = bool
default = false
}
data "aws_route53_zone" "main" {
count = var.create_dns_records ? 1 : 0
name = "myapp.com"
}
resource "aws_route53_record" "app" {
count = var.create_dns_records ? 1 : 0
zone_id = data.aws_route53_zone.main[0].zone_id
name = "app.myapp.com"
type = "A"
# ...
}
Anti-Pattern 6: Data Sources Querying Their Own Resources #
Some developers use a data source to read a resource that exists in the same configuration. This isn’t needed — a direct reference is cleaner and more efficient.
# ANTI-PATTERN: A data source to read a resource in the same configuration
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = aws_vpc.main.id
}
# No need for a data source to read a just-created security group!
data "aws_security_group" "web" {
name = "web-sg"
vpc_id = aws_vpc.main.id
depends_on = [aws_security_group.web] # ✗ Going in circles
}
resource "aws_instance" "web" {
vpc_security_group_ids = [data.aws_security_group.web.id] # ✗ Not needed
}
# CORRECT: Direct reference to the resource
resource "aws_instance" "web" {
vpc_security_group_ids = [aws_security_group.web.id] # ✓ Direct and clean
}
flowchart TD
A["📋 Best Practices"] --> B["Cache data source results\nin local values"]
A --> C["Use depends_on\nonly when needed"]
A --> D["Prefer variables\nfor known values"]
A --> E["Minimize data sources\nin the hot path"]
style A fill:#3b82f6,stroke:#1e40af,color:#fff
style B fill:#10b981,stroke:#059669,color:#fff
style C fill:#f59e0b,stroke:#d97706,color:#fff
style D fill:#8b5cf6,stroke:#6d28d9,color:#fff
style E fill:#10b981,stroke:#059669,color:#fffAnti-Pattern: Data Sources Returning Too Much Data #
# ANTI-PATTERN: Fetching all instances without a filter
data "aws_instances" "all" {
# Returns ALL instances in the entire account
# Could be thousands of instances → slow and expensive API calls
}
# CORRECT: Filter as early as possible
data "aws_instances" "web" {
filter {
name = "tag:Role"
values = ["web-server"]
}
filter {
name = "instance-state-name"
values = ["running"]
}
}
# Performance impact:
# Without a filter: API call can take 5-10 seconds on a large account
# With a filter: API call < 1 second
# In CI/CD pipelines run hundreds of times, this is very noticeable
Anti-Pattern: Data Sources in Overly Generic Modules #
Data sources in modules should be specific, not generic.
# ANTI-PATTERN: Generic data source in a module
module "app" {
source = "./modules/app"
# The module itself looks for the AMI, VPC, subnet
# Every time it's called, the data source is called again
}
# Inside the module:
data "aws_vpc" "main" {
tags = { Environment = var.environment }
}
# PROBLEM: Can match multiple VPCs, not predictable
# CORRECT: Pass IDs from the root module
module "app" {
source = "./modules/app"
vpc_id = data.aws_vpc.main.id # The root decides the VPC
subnet_ids = data.aws_subnets.private.ids
}
# Inside the module, just use what's passed:
variable "vpc_id" { type = string }
variable "subnet_ids" { type = list(string) }
Anti-Pattern: Data Sources in Loops #
# ANTI-PATTERN: Data sources inside count/for_each
# Every iteration = a separate API call!
resource "aws_instance" "web" {
count = 10
# Every instance = 1 data source call = 10 API calls!
ami = data.aws_ami.per_instance[count.index].id
}
data "aws_ami" "per_instance" {
count = 10
most_recent = true
# ... filters
}
# CORRECT: Call the data source ONCE, use it in all resources
data "aws_ami" "latest" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["amzn2-ami-hvm-*"]
}
}
resource "aws_instance" "web" {
count = 10
ami = data.aws_ami.latest.id # One data source, many references
}
Anti-Pattern: Hardcoded Resource IDs #
# ANTI-PATTERN: Hardcoded resource ID in a data source
data "aws_instance" "web" {
instance_id = "i-1234567890abcdef0" # Hardcoded!
}
# CORRECT: Use filters or references
data "aws_instances" "web" {
filter {
name = "tag:Name"
values = ["web-server"]
}
filter {
name = "instance-state-name"
values = ["running"]
}
}
# Or use a direct reference
resource "aws_instance" "web" {
# ...
}
# A direct reference is faster than a data source
output "web_ip" {
value = aws_instance.web.public_ip # Direct reference
# NOT: data.aws_instance.web.public_ip
}
Summary #
- Filters must be specific enough to always return exactly one result — an ambiguous filter works in dev but can error in production.
- Data sources aren’t an import replacement — if you want Terraform to manage a resource, import it into state. Data sources only read, they don’t manage.
- Limit
terraform_remote_stateusage — excessive coupling between workspaces makes configurations hard to run in isolation. Consider input variables as an alternative.- Don’t query via a data source for values that can be passed as variables — variables make dependencies more explicit and configurations more predictable.
- Use
countfor data sources that may not exist in every environment — prevents plans from failing just because a resource doesn’t exist in a particular environment.- Reference resources directly within the same configuration — no need for a data source to read a resource that’s in the same configuration.