Terraform ignore_changes: A Comprehensive Guide

ignore_changes tells Terraform to stop proposing updates to specific resource attributes on every subsequent plan. That’s all it does. It’s not a lock, it’s not a freeze, and it doesn’t pull a resource out of Terraform’s management — a surprisingly common misconception. A resource with ignore_changes set will still get destroyed and recreated if something else forces that (a change to a force-new argument, a tainted resource, a -replace flag), and it still shows up in terraform destroy like anything else. All ignore_changes suppresses is the diff for the attributes you list, on the attributes you list, going forward from when you add it.

That last clause matters more than most explanations give it credit for, and it’s the source of the second-most-common mistake: assuming it retroactively cleans up drift that already happened. It doesn’t. More on that below, after the syntax.

Basic Syntax

ignore_changes lives inside a lifecycle block, nested inside the resource it applies to:

resource "aws_instance" "web" {
  ami           = "ami-0c55b159cbfafe1f0"
  instance_type = "t3.medium"

  tags = {
    Name = "web-server"
  }

  lifecycle {
    ignore_changes = [ami]
  }
}

With this in place, if someone (or some automation) swaps the AMI on the running instance outside of Terraform, terraform plan won’t flag it and won’t try to put the original AMI back. Everything else about the resource — instance_type, tags, whatever else you define — is still fully managed and will still diff normally.

You can list more than one attribute:

lifecycle {
  ignore_changes = [ami, instance_type, tags["LastModified"]]
}

Note the indexing syntax — tags["LastModified"] targets one key in a map rather than the whole map. The same pattern works for list elements (some_list[0]). This is the mechanism you’d reach for when a cloud provider or external process only ever touches one specific key or index, and you want Terraform to keep managing the rest of the attribute normally.

ignore_changes = all vs Specific Attributes

These two forms do genuinely different things, and conflating them causes real incidents.

A specific list only ignores the named attributes. Everything else on the resource is still diffed and updated as normal:

lifecycle {
  ignore_changes = [tags]
}

This says: never touch tags again after creation, but keep managing everything else — instance size, security groups, user data, all of it — exactly as before.

all is a different animal entirely:

lifecycle {
  ignore_changes = all
}

Per HashiCorp’s docs, this “instructs Terraform to ignore all attributes, which means that Terraform can create and destroy the remote object but will never propose updates to it.” That’s a bigger decision than it looks. The resource becomes effectively write-once — Terraform still creates it and still destroys it on removal or forced replacement, but no attribute drift ever appears in a plan again, including changes you make deliberately in the .tf file. Edit the resource’s config after setting ignore_changes = all expecting apply to push that change, and it won’t. That’s the point of the setting, but it trips people up when they forget it’s there.

all is useful for resources Terraform needs to own the lifecycle of (so it can create and destroy them as part of a larger stack) but shouldn’t actually manage the configuration of — think a resource bootstrapped by Terraform and then handed off entirely to another tool, a Kubernetes operator, or a human runbook. For the far more common case — “one or two attributes get changed by something else, manage the rest normally” — use the specific-attribute list.

Common Real-World Use Case: Externally-Managed Attributes

The use case that actually shows up in production, repeatedly, is attributes that something other than Terraform legitimately needs to change after the resource exists.

Tags added by cloud-provider automation. AWS Auto Scaling Groups, for instance, can be configured to propagate tags onto the instances they launch, and some AWS services write their own operational tags (cost-allocation tags, backup-schedule tags from AWS Backup, service-linked tags) directly onto resources. If Terraform doesn’t know about those tags, it doesn’t try to remove them — but if Terraform does manage the tags map and something external adds a key to it, the next plan will show Terraform wanting to delete that key, because it’s not in your .tf file. That’s not a bug, it’s Terraform correctly doing its job of reconciling reality to your declared config — but it’s not what you want when the “reality” was added on purpose by something else you also trust.

resource "aws_autoscaling_group" "app" {
  name     = "app-asg"
  min_size = 2
  max_size = 10

  tag {
    key                 = "Environment"
    value               = "production"
    propagate_at_launch = true
  }

  lifecycle {
    ignore_changes = [tag]
  }
}

Desired count on an autoscaled ECS service. This is the single most common real-world trigger for ignore_changes in the wild. An ECS service managed by Terraform typically declares desired_count as part of its initial configuration, but if that service also has an Application Auto Scaling target attached (aws_appautoscaling_target / aws_appautoscaling_policy), the autoscaler is going to change the running count based on load — and if Terraform isn’t told to back off, the next terraform apply will forcibly scale the service back down (or up) to whatever’s hardcoded in the .tf file, fighting the autoscaler and potentially causing an outage during a traffic spike.

resource "aws_ecs_service" "app" {
  name            = "app-service"
  cluster         = aws_ecs_cluster.main.id
  task_definition = aws_ecs_task_definition.app.arn
  desired_count   = 2  # only used on initial creation

  lifecycle {
    ignore_changes = [desired_count]
  }
}

With this, Terraform sets desired_count once on creation and then leaves it alone forever, letting the autoscaling policy own it exclusively from that point on. The same pattern applies to desired_capacity on an aws_autoscaling_group managed by scaling policies.

What ignore_changes Does NOT Do

It does not prevent the resource from being destroyed or recreated

ignore_changes only suppresses the update diff for the named attributes. It has no effect on replacement triggered by other causes: changing an attribute that forces a new resource, running terraform apply -replace=<resource_address>, tainting the resource, or removing the resource block entirely (which still plans a destroy). People sometimes reach for ignore_changes expecting it to “lock” a resource so nothing can ever touch it — that’s not this setting. If you want to prevent destruction specifically, that’s prevent_destroy in the same lifecycle block, and it’s a separate concern.

It does not retroactively fix drift that already happened

This is the one that catches people off guard. ignore_changes only affects what Terraform does starting from the next plan after you add it — it does nothing to reconcile drift that occurred before. If an attribute already diverged from your .tf config before you wrote the ignore_changes line, state still holds whatever value was there at last refresh. Adding ignore_changes afterward just papers over that divergence going forward — it doesn’t correct state to match live reality, and it doesn’t correct live reality to match your config either. If you need an accurate baseline before you start ignoring future changes, run terraform apply once (if safe) or terraform refresh first, then add ignore_changes.

It requires a static list — not a variable or computed expression

You cannot pass a variable, a local, or any computed expression into ignore_changes. HashiCorp’s docs are explicit about why: “All lifecycle settings affect how Terraform constructs and traverses the dependency graph… only literal values can be used because the processing happens too early for arbitrary expression evaluation.” In practice this means ignore_changes = var.ignored_attrs fails — it has to be a literal HCL tuple of attribute names known at parse time, like ignore_changes = [tags, ami].

This also means there’s no built-in way to vary ignore_changes per instance of a count or for_each resource. The lifecycle block is attached once to the resource block, and that same static list applies identically to every instance the resource generates — there’s no equivalent of each.key or a conditional inside it. If you need genuinely different ignore behavior per instance, you’re generally looking at splitting the resource into separate blocks or a module boundary, not a parameterized lifecycle block. There’s also a long-standing open issue in the Terraform repo (hashicorp/terraform#29447) where ignore_changes targeting a nested-block attribute by key (e.g. rules["subnets"]) on a for_each resource can still surface a diff after apply — worth testing carefully if you’re ignoring anything more complex than a top-level scalar attribute on a for_each/count resource.

Does OpenTofu Behave Differently?

As of OpenTofu’s current documentation, no — the behavior is the same as Terraform’s (see our full OpenTofu vs Terraform comparison for everything else that does or doesn’t carry over). ignore_changes takes either all or a static list of attribute names, and the same literal-values-only constraint applies for the same reason (lifecycle settings are resolved during graph construction, before expressions can be evaluated). An OpenTofu example straight from this pattern: ignore_changes = [ami, user_data, tags["LastModified"]].

One thing worth watching if you’re on OpenTofu: there’s an open feature request (opentofu/opentofu#2525) proposing to loosen this specifically for the bracketed portion of an attribute path — letting a module author use a caller-supplied value as a map key or list index inside ignore_changes, rather than the whole thing being a build-time constant. It’s still at the RFC/design-discussion stage with no implementation, so don’t write code assuming it exists yet — but if you maintain modules where callers keep asking for a parameterized ignore list, it’s the thing to track.

Frequently Asked Questions

Can I use a variable or local value inside ignore_changes?

No. It must be a literal list of attribute names (or the keyword all), because Terraform resolves lifecycle settings while building its dependency graph, before it evaluates expressions. ignore_changes = var.attrs or any computed value will fail.

Does ignore_changes stop a resource from being replaced?

No. It only suppresses the update diff for the listed attributes. A resource can still be destroyed and recreated by a forces-new-resource change elsewhere, a manual -replace, a taint, or removal of the resource block. Use prevent_destroy if you want to block destruction specifically.

If I remove ignore_changes later, what happens?

Whatever drift accumulated while it was in effect will suddenly show up in the next plan — all at once, potentially as a large unexpected diff. If the ignored attribute is one that forces replacement when changed, removing ignore_changes can surface a destroy/recreate you weren’t expecting. Check the plan output carefully before applying right after removing an ignore_changes entry.

Does it work correctly with count or for_each?

The lifecycle block (and whatever’s in ignore_changes) applies identically to every instance generated by count or for_each — you can’t vary it per instance. For simple top-level attributes this works as expected; for nested-block attributes referenced by key on a for_each resource, there’s at least one open upstream issue where the diff isn’t always fully suppressed, so verify with a real plan rather than assuming.

What’s the difference between ignore_changes and prevent_destroy?

ignore_changes suppresses update diffs on specific attributes but has no effect on destroy/replace. prevent_destroy does the opposite — it blocks Terraform from destroying the resource at all (erroring out the apply if a destroy is planned) but has no effect on ordinary attribute updates. They solve different problems and are often used together on the same resource for different reasons.

Quick Summary

  • ignore_changes suppresses the plan diff for specific attributes (or all of them, via the all keyword) from the point it’s added onward — it is not a lock on the resource.
  • It does not prevent destroy or replacement caused by anything else; use prevent_destroy for that.
  • It does not retroactively fix drift that happened before it was added — reconcile state first if you need an accurate baseline.
  • It requires a static, literal list of attribute names (or all) — no variables, locals, or computed expressions, because lifecycle settings resolve during graph construction.
  • The same static list applies to every instance of a count/for_each resource; nested-block attributes by key can have rough edges on for_each resources (see upstream issue #29447).
  • The most common legitimate use: externally-managed tags and autoscaler-owned fields like ECS desired_count or ASG desired_capacity.
  • OpenTofu matches Terraform’s current behavior exactly; a proposal to allow dynamic map/list keys inside ignore_changes is open but not yet implemented (opentofu/opentofu#2525).