Terraform Data Sources
Learn how Terraform data sources read existing infrastructure, how data blocks work, how outputs are referenced, and when to use data sources instead of resources.
What Are Data Sources?
Terraform data sources let you read information about infrastructure that already exists without telling Terraform to create, update, or destroy that infrastructure. They are one of the most important ideas for beginners because real Terraform projects rarely operate in a completely empty cloud account.
In practice, teams often have a mixture of new and existing resources:
- a VPC created months ago by another team
- shared subnets used by many applications
- an approved AMI that changes over time
- the current AWS account ID
- the active AWS region chosen by the provider
- availability zones offered in that region
Your Terraform code may need to discover those values before it can create anything new. That is the job of a data source.
A resource says, "Terraform, manage this object for me." A data source says, "Terraform, look this object up and give me its details."
That distinction is simple, but it changes how you design infrastructure code. If you need to launch an EC2 instance inside an existing VPC, you should not create a second VPC just because Terraform needs a VPC ID. Instead, you read the existing VPC with a data source and then feed that ID into your new resources.
Why Data Sources Matter in Real Projects
Beginners sometimes start with examples where every resource is created from scratch in one directory. That is useful for learning syntax, but it is not how most production environments work.
In a real AWS account, infrastructure is usually shared and layered:
- a network team may own the VPC and core routing
- a platform team may publish approved machine images
- a security team may define existing IAM roles or KMS keys
- an application team may create only the parts related to their service
Your module often needs to connect to those shared building blocks. Data sources make that possible without taking ownership away from the original team.
They also improve portability. Instead of hardcoding values like AMI IDs or subnet IDs, you can make Terraform query AWS dynamically. That means your configuration can adapt across regions, accounts, or environments with fewer manual edits.
The data Block Syntax
A Terraform data source is declared with a data block:
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
}
The structure is similar to a resource block:
data "TYPE" "NAME" {
# configuration
}
TYPEis the provider-specific data source type, such asaws_amiNAMEis the local Terraform name you choose, such asubuntu- the block body contains arguments used to perform the lookup
This example asks AWS to find the most recent Ubuntu AMI owned by Canonical and matching the naming pattern given in the filter.
Even though the syntax looks familiar, remember the intent is different from a resource block. Terraform is not creating an AMI. It is only retrieving details about one.
Referencing Data Source Outputs
Once Terraform reads a data source, you can use its attributes elsewhere in the configuration.
The general format is:
data.<type>.<name>.<attribute>
For the previous example, the AMI ID is referenced like this:
data.aws_ami.ubuntu.id
You can then pass that value directly into a resource:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
}
This is the core workflow of data sources:
- look something up
- read its attributes
- use those attributes in resources, locals, outputs, or modules
Because the value comes from a provider query, you avoid hardcoding details that may change over time.
Common AWS Data Sources You Will Use Often
Terraform providers ship with many data sources, but a small set appears in beginner and intermediate AWS projects again and again.
aws_ami
aws_ami is one of the most common AWS data sources. It lets you find an Amazon Machine Image by owner, name pattern, architecture, virtualization type, or other filters.
Example:
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
Why it matters:
- AMI IDs are region-specific
- AMI IDs change as publishers release patched images
- hardcoding an old AMI leads to drift and security issues
A data source lets you ask AWS for the latest approved image each time Terraform plans.
aws_vpc
aws_vpc finds an existing VPC. You might search by tags, by default status, or by known attributes.
data "aws_vpc" "main" {
filter {
name = "tag:Name"
values = ["shared-main-vpc"]
}
}
You can then reference values such as:
data.aws_vpc.main.iddata.aws_vpc.main.cidr_blockdata.aws_vpc.main.default_network_acl_id
This is useful when your application module must deploy into a VPC that already exists.
aws_subnet_ids and related subnet lookups
Historically, aws_subnet_ids has been used to retrieve subnet IDs from a VPC. A simple example looks like this:
data "aws_subnet_ids" "private" {
vpc_id = data.aws_vpc.main.id
tags = {
Tier = "private"
}
}
That allows you to feed subnet IDs into load balancers, Auto Scaling groups, EKS node groups, or other resources that need multiple subnets.
Depending on provider version, you may also see newer patterns using aws_subnets or a combination of subnet data sources. The learning goal is the same: Terraform can query subnet information rather than requiring you to paste IDs manually.
aws_caller_identity
This data source tells you who Terraform is authenticated as.
data "aws_caller_identity" "current" {}
Common attributes include:
data.aws_caller_identity.current.account_iddata.aws_caller_identity.current.arndata.aws_caller_identity.current.user_id
This is extremely useful for dynamic naming, IAM policy generation, or account-aware logic.
For example:
locals {
logs_bucket_name = "app-logs-${data.aws_caller_identity.current.account_id}"
}
aws_region
This data source returns the current AWS region configured by the provider.
data "aws_region" "current" {}
You can reference:
data.aws_region.current.name
This is helpful when your module must build ARNs, tags, or names that include the current region.
aws_availability_zones
This data source retrieves the availability zones in the current region.
data "aws_availability_zones" "available" {
state = "available"
}
Common use cases:
- creating subnets across multiple AZs
- selecting the first two or three zones in a region
- keeping code generic across regions
Example:
resource "aws_subnet" "public" {
count = 2
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = data.aws_availability_zones.available.names[count.index]
}
Filtering Data Sources
Most useful data sources support filtering. Filters are how you narrow down the provider search until Terraform can identify the exact object or objects you want.
A common pattern uses one or more filter blocks:
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
filter {
name = "architecture"
values = ["x86_64"]
}
}
Each filter has:
namefor the field AWS should matchvaluesfor one or more acceptable values
When you combine filters, Terraform asks the provider to return objects matching all of them.
Using most_recent
With image lookups, several results may match the same pattern. Setting most_recent = true tells Terraform to pick the newest one.
That is especially useful for AMIs because image IDs change frequently. You usually care about a stable naming pattern and the newest published version, not a fixed AMI ID that grows stale.
Why filtering matters
Poorly designed filters can make data sources unreliable. If your filter is too broad, Terraform may find the wrong object. If it is too narrow, the lookup may fail when naming conventions change.
Good filters are:
- specific enough to return the right result
- stable across environments
- based on trustworthy tags, owners, or naming patterns
Data Sources vs Resources
This is the most important conceptual comparison in the tutorial.
| Terraform concept | Purpose | Managed by Terraform? | Example |
|---|---|---|---|
resource | Create or manage infrastructure | Yes | resource "aws_instance" "web" {} |
data | Read existing infrastructure or provider information | No | data "aws_ami" "ubuntu" {} |
A resource is part of Terraform's managed infrastructure graph. Terraform tracks it in state and may create, update, or delete it.
A data source is read-only. Terraform tracks the fact that the lookup exists in the dependency graph, but it does not own the remote object.
When to use a resource
Use a resource when you want Terraform to manage the lifecycle of something:
- create an EC2 instance
- create an S3 bucket
- create a VPC
- create an IAM role
When to use a data source
Use a data source when the object already exists or is generated outside your configuration:
- find the latest Ubuntu AMI
- look up an existing VPC by tag
- retrieve your current AWS account ID
- discover available zones in the current region
A helpful decision rule
Ask yourself: Should Terraform own this object?
- If yes, use a resource.
- If no, but you need its attributes, use a data source.
That rule prevents a lot of beginner confusion.
Data Sources and Terraform State
Data sources do participate in Terraform's graph, but they behave differently from resources in state.
A key thing to remember is this:
Data sources are refreshed during planning.
That means every time you run terraform plan, Terraform asks the provider for fresh values. If the latest Ubuntu AMI changed since yesterday, your data source result can change too.
This is powerful, but it also means data-driven configurations can behave differently over time without any .tf file edits.
For example:
- today,
data.aws_ami.ubuntu.idreturns one AMI ID - next week, Canonical publishes a newer AMI
- the same configuration now resolves to a new ID
- Terraform may show that your EC2 instance needs replacement if the AMI changed
That is not a bug. It is the expected consequence of choosing a dynamic lookup.
Practical implication
Dynamic lookups reduce manual maintenance, but they can introduce change unexpectedly. In production, some teams prefer to pin values after testing, while others intentionally want the latest approved image every time.
The right choice depends on your operational model.
Dependency Behavior with Data Sources
Terraform automatically understands dependencies when one object references another.
Example:
data "aws_vpc" "main" {
filter {
name = "tag:Name"
values = ["shared-main-vpc"]
}
}
resource "aws_security_group" "web" {
name = "web-sg"
vpc_id = data.aws_vpc.main.id
}
Because the security group references data.aws_vpc.main.id, Terraform knows it must resolve the VPC lookup before planning the security group.
The same principle works when data sources feed locals, modules, and outputs.
A Complete Practical Example: Find the Latest Ubuntu AMI Dynamically
Now let us put the pieces together in a practical example. Suppose you want to launch an EC2 instance, but you do not want to hardcode the AMI ID. You want Terraform to find the latest Ubuntu 22.04 AMI in the current region.
provider "aws" {
region = var.aws_region
}
variable "aws_region" {
description = "AWS region for deployment"
type = string
default = "us-east-1"
}
variable "instance_type" {
description = "EC2 instance type"
type = string
default = "t3.micro"
}
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
tags = {
Name = "ubuntu-web"
}
}
output "selected_ami_id" {
value = data.aws_ami.ubuntu.id
}
What this configuration does
- configures the AWS provider
- defines a region variable and instance type variable
- asks AWS for the latest matching Ubuntu AMI
- launches an EC2 instance using the returned AMI ID
- outputs the chosen AMI ID
Why this is better than hardcoding
If you hardcode an AMI ID, your configuration becomes tightly tied to one specific image in one region. That creates several problems:
- AMI IDs differ by region
- old AMIs stop receiving fixes
- updating the image becomes a manual task
- copied examples become stale quickly
With a data source, the configuration is more portable and easier to maintain.
What to watch out for
Because most_recent = true is dynamic, a future terraform plan may choose a newer AMI and propose instance replacement. That may be desirable in a learning lab, but in production you should be intentional about when image updates happen.
Another Example: Reading Existing Network Information
Data sources become even more valuable when your code integrates with a shared environment.
Imagine a VPC already exists, and your Terraform project should only create an EC2 instance inside that network.
data "aws_vpc" "main" {
filter {
name = "tag:Name"
values = ["shared-main-vpc"]
}
}
data "aws_subnet_ids" "private" {
vpc_id = data.aws_vpc.main.id
tags = {
Tier = "private"
}
}
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
subnet_id = data.aws_subnet_ids.private.ids[0]
}
Here, Terraform does not manage the VPC or subnets. It simply discovers them and uses them.
That pattern is extremely common in organizations with shared networking.
Best Practices for Using Data Sources Well
1. Prefer stable filters over fragile ones
Filtering by trusted tags, known owners, and consistent naming conventions is usually safer than matching vague patterns.
2. Avoid unnecessary hardcoding
If something naturally changes over time or varies by region, a data source may be a better fit than a literal value.
3. Be intentional with dynamic lookups
Dynamic data is useful, but it also means the same configuration can plan differently next week. That is powerful, not magical. Treat it carefully.
4. Keep the ownership boundary clear
Do not use data sources as a substitute for proper module boundaries. If your team should manage the resource, define it as a resource. If another team owns it, read it as data.
5. Output important discovered values during learning
When you are new to Terraform, outputs help you see what the lookup actually returned:
output "vpc_id" {
value = data.aws_vpc.main.id
}
That makes debugging much easier.
Common Beginner Mistakes
Mistake 1: Expecting a data source to create infrastructure
A data block never creates a VPC, AMI, subnet, or identity. It only reads what already exists.
Mistake 2: Using filters that return multiple ambiguous matches
If your lookup is not specific enough, Terraform may fail or return something unexpected.
Mistake 3: Hardcoding values that should be discovered
If the AMI ID, region, or availability zones vary, static values often become maintenance pain.
Mistake 4: Forgetting that data sources refresh on every plan
This is especially important for most_recent image lookups. If the data changes, your plan can change.
Final Thoughts
Terraform data sources are the bridge between new infrastructure you want Terraform to manage and existing infrastructure or provider facts Terraform needs to read. Once you understand that boundary, many real-world Terraform patterns become much easier to reason about.
The mental model is simple:
- resources manage infrastructure
- data sources read infrastructure or provider metadata
- references connect those values into the rest of your configuration
If you use data sources thoughtfully, your Terraform code becomes more reusable, more environment-aware, and better aligned with how real cloud platforms are shared across teams.
Knowledge Check
Question 1: Data Source Purpose
What is the main purpose of a Terraform data source?
Question 2: Referencing Data
How do you reference the id attribute from a data source declared as data "aws_ami" "ubuntu"?
Question 3: Dynamic Refresh
Why can a Terraform plan change over time when using data "aws_ami" with most_recent = true?