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:#fff

Anti-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:#fff


Anti-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_state usage — 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 count for 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.

← Previous: Reference   Next: What is a Module? →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact