Terraform lifecycle ignore_changes

Terraform State Management Best Practices

What is Terraform lifecycle ignore_changes?

ignore_changes is an argument inside a resource's lifecycle meta-argument block. It has a simple job: telling Terraform to stop comparing specific resource attributes against their current state when it plans an update. Terraform still considers those attributes the first time it creates the resource; from the next terraform plan onward, whatever value the attribute holds in the real infrastructure is no longer relevant to the diff.

Basic Terraform lifecycle ignore_changes syntax

The ignore_changes argument inside a lifecycle block takes one of two literal forms: a list of specific attribute references, or the all keyword.

resource "aws_instance" "example" {
ami = "ami-0a1b2c3d4e5f67890"
instance_type = "m6i.large"
tags = {
Name = "example"
}
lifecycle {
ignore_changes = [tags["Name"]]
}
}

Swap the list for ignore_changes = all, and every argument on the resource becomes exempt from the diff, not just tags["Name"]. Both forms sit inside the same lifecycle block, and neither accepts anything beyond a literal attribute name or the all keyword.

What Terraform lifecycle ignore_changes actually does

This argument is meant to be simple. Most Terraform-managed resources need to be fully described by configuration, but a minority end up shared: created by Terraform, then handed to a separate system, script, or human process that then owns one or two fields into the future. ignore_changes enables Terraform to step back from those fields without stepping back from the resource entirely.

The attribute list inside ignore_changes takes relative addresses within the resource: a whole top-level argument like tags, or one element inside it addressed by map key or list index, such as tags["Owner"] or vpc_security_group_ids[0].

Terraform resolves these against the resource's own schema, and only against attributes the resource type actually defines. You can't point ignore_changes at another meta-argument, or at itself.

Every reference in that list has to be a literal value. A variable or a conditional expression won't work here: Terraform resolves lifecycle settings while it's still building the dependency graph, well before it starts evaluating the expressions inside a resource body, so anything dynamic simply arrives too late. That single restriction is important once we start to cover common mistakes further down.

It's also easy to miss the fact that ignore_changes doesn't just suppress one line in a plan. Once an attribute is listed, Terraform stops reconciling it entirely, even when the same resource changes for an unrelated reason.

Add a new tag alongside one that's already drifted under ignore_changes, and Terraform updates the new tag while leaving the drifted one untouched. It's not a per-apply suppression; it's a standing instruction, and the same logic scales all the way up to ignore_changes = all – the resource still gets created and destroyed on schedule, it just never proposes an update to any argument again, drifted or not.

An example of Terraform lifecycle ignore_changes in action

Here is a concrete example, where a fleet of instances gets created through Terraform, but a separate compliance agent rewrites one tag after boot to record a scan timestamp:

resource "aws_instance" "worker" {
ami = "ami-0a1b2c3d4e5f67890"
instance_type = "m6i.large"
tags = {
Name = "worker-node"
Team = "platform"
}
lifecycle {
ignore_changes = [tags["LastScanned"]]
}
}

Terraform creates the instance with whatever tags the configuration supplies, with LastScanned included if one's present. From then on, the compliance agent can rewrite LastScanned freely; terraform plan won't flag it as drift and won't try to revert it.

Every other key in tags, Name and Team in this case, still gets treated normally by Terraform: change either in configuration, and the next apply updates it.

That distinction, ignoring one map key rather than the whole tags block, is important. Ignore the entire map with tags instead of tags["LastScanned"], and an intentional rename of Name in configuration stops taking effect too, silently.

Common mistakes users make with Terraform lifecycle ignore_changes

ignore_changes is a resource-level argument in Terraform code, not a module-level or provider-level one (a distinction that explains most of the confusion below once someone tries to point it at a module block instead of a resource block).

Wanting Terraform lifecycle ignore_changes to be conditional is the most common one: ignoring an attribute in one environment but not another, based on an input variable or a count/for_each key.

It can't be done directly. ignore_changes has no equivalent of a ternary or a lookup; the list itself has to be a fixed set of attribute names known at parse time, for the reason covered above.

Another mistake is expecting ignore_changes to work by attaching the argument to a module call itself, and expecting it to reach every resource the module creates.

A module block has no lifecycle argument at all; only resource, data, and ephemeral blocks do (and only for precondition/postcondition blocks at that), with ignore_changes specifically a resource-only rule even among those.

The lifecycle block has to live on the resource inside the module, which means the module's author decides what's ignorable, or exposes a boolean input variable the module uses internally to pick between two resource definitions.

Using ignore_changes = all out of convenience rather than necessity is another common error. If only one or two fields need to be ignored, all hides drift on everything else too, including changes worth catching. Treat it as a deliberate, resource-wide decision, not a shortcut for skipping the work of figuring out which attributes are actually important.

The nearest alternative to Terraform lifecycle ignore_changes

When the goal is handing off one or two fields, ignore_changes is the right choice. When the goal is something else, there are two better options.

For the conditional case above, define two versions of the resource, one with the lifecycle block and one without, and select between them with count, driven by whatever variable was going to control the condition:

resource "aws_instance" "worker_managed" {
count = var.externally_managed ? 0 : 1
ami = "ami-0a1b2c3d4e5f67890"
instance_type = "m6i.large"
}
resource "aws_instance" "worker_ignored" {
count = var.externally_managed ? 1 : 0
ami = "ami-0a1b2c3d4e5f67890"
instance_type = "m6i.large"
lifecycle {
ignore_changes = [tags]
}
}

One caveat is that the two resources have different addresses, so flipping the variable after the instance exists means Terraform plans a destroy and a create, not a rename. If the resource is already live, move it between addresses first with a moved block or terraform state mv, then flip the variable.

For a resource that's fully transitioned to being managed elsewhere rather than sharing one field, dropping it from Terraform's management with terraform state rm has the same instinct behind ignore_changes – cutting a management link cleanly, just scoped to an entire resource instead of a single attribute.

When and when not to use Terraform lifecycle ignore_changes

The clearest legitimate case looks like the compliance-agent example above, generalized: Terraform creates a resource once, and then a separate system, a UI, or an automated step in a CI/CD pipeline, takes over one or two fields permanently.

ignore_changes is what formalizes that hand-off in configuration, rather than pretending Terraform still owns a field it doesn't.

However, this argument can also be mistakenly used as a band-aid, particularly when terraform plan keeps proposing a change with nothing to do with an external system.

Maybe a provider defaults a value differently than expected, or an attribute that legitimately belongs in configuration keeps drifting because someone edits the console directly, with no intention of keeping it that way. Using ignore_changes in these situations stops Terraform from mentioning it every apply from then on, without solving any mismatch.

In order to work out if what you're doing is the right move, ask yourself if the attribute changes who's supposed to own it, or just changes whether Terraform mentions the disagreement.

ignore_changes makes drift invisible to Terraform by design, useful when ownership has genuinely moved elsewhere, but not when the ownership question was never actually settled in the first place.

Try Stategraph free to see what tracking dependencies through a real graph adds once ignoring a field is only part of the picture.

Related Terraform terms

A handful of other Terraform concepts sit close enough to ignore_changes to be worth knowing:

  • Terraform state – Worth understanding directly when the drift a field is hiding lives in the state file itself, not just the next plan diff.
  • Terraform's dependency graph – The graph-construction step that forces ignore_changes into literal values in the first place.
  • Terraform state locking – A different tool for a related problem: contested access to a resource, rather than one field's drift.

Terraform lifecycle ignore_changes FAQs

Can I remove an attribute from ignore_changes later?

Yes. Delete the entry and Terraform resumes comparing that attribute normally on the next plan. Any drift that built up while it was ignored surfaces immediately and reconciles on the following apply.

Does ignore_changes behave differently across count or for_each instances of the same resource?

No. The lifecycle block is defined once per resource, not per instance, so the same ignore_changes list applies identically across every instance a count or for_each resource creates.

Does OpenTofu support ignore_changes the same way?

Yes. OpenTofu forked Terraform's configuration language wholesale, and ignore_changes behaves identically: same syntax, same literal-values restriction, still scoped to resource blocks only.