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:
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.
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:
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.
data Block SyntaxA 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
}TYPE is the provider-specific data source type, such as aws_amiNAME is the local Terraform name you choose, such as ubuntuThis 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.
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.idYou 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:
Because the value comes from a provider query, you avoid hardcoding details that may change over time.
Terraform providers ship with many data sources, but a small set appears in beginner and intermediate AWS projects again and again.
aws_amiaws_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:
A data source lets you ask AWS for the latest approved image each time Terraform plans.
aws_vpcaws_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_idThis is useful when your application module must deploy into a VPC that already exists.
aws_subnet_ids and related subnet lookupsHistorically, 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_identityThis 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_idThis 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_regionThis data source returns the current AWS region configured by the provider.
data "aws_region" "current" {}You can reference:
data.aws_region.current.nameThis is helpful when your module must build ARNs, tags, or names that include the current region.
aws_availability_zonesThis data source retrieves the availability zones in the current region.
data "aws_availability_zones" "available" {
state = "available"
}Common use cases:
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]
}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:
name for the field AWS should matchvalues for one or more acceptable valuesWhen you combine filters, Terraform asks the provider to return objects matching all of them.
most_recentWith 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.
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:
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.
Use a resource when you want Terraform to manage the lifecycle of something:
Use a data source when the object already exists or is generated outside your configuration:
Ask yourself: Should Terraform own this object?
That rule prevents a lot of beginner confusion.
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:
data.aws_ami.ubuntu.id returns one AMI IDThat is not a bug. It is the expected consequence of choosing a dynamic lookup.
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.
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.
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
}If you hardcode an AMI ID, your configuration becomes tightly tied to one specific image in one region. That creates several problems:
With a data source, the configuration is more portable and easier to maintain.
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.
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.
Filtering by trusted tags, known owners, and consistent naming conventions is usually safer than matching vague patterns.
If something naturally changes over time or varies by region, a data source may be a better fit than a literal value.
Dynamic data is useful, but it also means the same configuration can plan differently next week. That is powerful, not magical. Treat it carefully.
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.
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.
A data block never creates a VPC, AMI, subnet, or identity. It only reads what already exists.
If your lookup is not specific enough, Terraform may fail or return something unexpected.
If the AMI ID, region, or availability zones vary, static values often become maintenance pain.
This is especially important for most_recent image lookups. If the data changes, your plan can change.
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:
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.
<MCQExercise exercise={{ id: 'tf-data-sources-q1', title: 'Question 1: Data Source Purpose', description: 'What is the main purpose of a Terraform data source?', options: [ { id: 'a', text: 'To create infrastructure objects and manage their lifecycle' }, { id: 'b', text: 'To read existing infrastructure or provider information without managing it' }, { id: 'c', text: 'To replace Terraform state files entirely' }, { id: 'd', text: 'To define reusable child modules' }, ], correctId: 'b', explanation: 'Data sources are read-only lookups. They let Terraform query existing infrastructure or provider metadata without taking ownership of that object.', wrongExplanations: { a: 'Creating and managing lifecycle belongs to resource blocks, not data blocks.', c: 'Data sources do not replace state files; they simply retrieve information during planning and apply.', d: 'Modules organize configuration, while data sources retrieve information from providers.', }, }} />
<MCQExercise exercise={{ id: 'tf-data-sources-q2', title: 'Question 2: Referencing Data', description: 'How do you reference the id attribute from a data source declared as data "aws_ami" "ubuntu"?', options: [ { id: 'a', text: 'resource.aws_ami.ubuntu.id' }, { id: 'b', text: 'aws_ami.ubuntu.id' }, { id: 'c', text: 'data.aws_ami.ubuntu.id' }, { id: 'd', text: 'var.aws_ami.ubuntu.id' }, ], correctId: 'c', explanation: 'Data source attributes are referenced with data.<type>.<name>.<attribute>, so data.aws_ami.ubuntu.id is correct.', wrongExplanations: { a: 'resource.<type>.<name> is not the syntax Terraform uses for references, and this block is not a resource.', b: 'Terraform references need the data. prefix for data sources.', d: 'var.<name> is used for input variables, not provider lookups.', }, }} />
<MCQExercise exercise={{ id: 'tf-data-sources-q3', title: 'Question 3: Dynamic Refresh', description: 'Why can a Terraform plan change over time when using data "aws_ami" with most_recent = true?', options: [ { id: 'a', text: 'Because Terraform deletes old AMIs automatically' }, { id: 'b', text: 'Because data sources are refreshed during planning and a newer matching AMI may exist later' }, { id: 'c', text: 'Because most_recent disables Terraform state' }, { id: 'd', text: 'Because count.index changes every time terraform plan runs' }, ], correctId: 'b', explanation: 'Terraform refreshes data sources during planning. If a newer AMI matches the same filters, the data source result may change and affect downstream resources.', wrongExplanations: { a: 'Terraform does not delete old AMIs just because a data source uses most_recent.', c: 'most_recent influences the lookup result, not whether Terraform uses state.', d: 'count.index is unrelated to AMI discovery through a data source.', }, }} />
A resource is managed: Terraform creates it, tracks it in state, updates it when configuration changes, and destroys it when removed. A data source is only read: it appears in state as a cached result but Terraform never modifies or deletes the underlying object. Use a data source for anything owned elsewhere, and a resource for anything this configuration is responsible for.
Use the aws_ami data source with filters and most_recent = true, or read the AMI ID from the SSM public parameters that AWS publishes with the aws_ssm_parameter data source. Both resolve the current image at plan time, which removes a value that goes stale and becomes a security liability. Be aware this means a new AMI can trigger instance replacement, so pair it with a launch template and an Auto Scaling group rather than a bare instance if you want control over when that happens.
During the plan, provided their arguments are known. If a data source depends on an attribute of a resource that does not yet exist, Terraform defers it to apply time and shows dependent values as (known after apply). This is why a data source that filters on a resource being created in the same run behaves differently from one querying pre-existing infrastructure, and why splitting such configurations into two applies is sometimes cleaner.
Yes, with terraform_remote_state, which reads the outputs of another state file. It works, but it couples the two configurations tightly. The consumer breaks if the producer's outputs change, and it needs read access to the other state, which contains secrets. Many teams prefer publishing values to SSM Parameter Store and reading them back with a data source, which is a looser contract and avoids granting state access.
The filters matched nothing, usually because of the region, the account, or a tag that does not exist. Data sources fail the plan rather than returning empty, which is deliberate. Continuing with a missing value would produce worse errors later. Check you are in the expected region and account with aws sts get-caller-identity, and test the equivalent CLI query directly to confirm the filters match something before debugging the Terraform side.
Choose between Terraform count and for_each: see why count's positional identity can destroy resources, when each is correct and how to migrate safely.
Learn how the Terraform count meta-argument works: use count.index, build conditional resources, reference counted resources and know when count fits.
Learn how Terraform for_each works with sets, maps and objects, how each.key and each.value are used, how to reference instances and when it beats count.