Skip to content
Infrastructure as Code iac resources 4 min read

null_resource & terraform_data

Sometimes you need a resource that does not map to any real infrastructure object — a hook to run a script when an input changes, or a place to park a computed value so Terraform can detect when it drifts. The classic tool for this is null_resource from the null provider, paired with the triggers argument and provisioners. As of Terraform 1.4, the built-in terraform_data resource replaces most uses of null_resource without requiring an external provider. This page covers both, their use cases, and their limits.

What null_resource does

null_resource is a resource that implements the standard resource lifecycle but takes no action of its own. Its value comes from two features: the triggers map and attached provisioners. When any value in triggers changes between plans, Terraform replaces the null_resource, which in turn re-runs any local-exec or remote-exec provisioners attached to it. This makes it a generic mechanism for “run this action whenever these inputs change.”

terraform {
  required_providers {
    null = {
      source  = "hashicorp/null"
      version = "~> 3.2"
    }
  }
}

resource "null_resource" "db_migrate" {
  triggers = {
    schema_version = filemd5("${path.module}/schema.sql")
    cluster_id     = aws_rds_cluster.main.id
  }

  provisioner "local-exec" {
    command = "psql -h ${aws_rds_cluster.main.endpoint} -f ${path.module}/schema.sql"
  }
}

Because schema_version is the MD5 of the SQL file, editing the migration changes the trigger, forces a replacement, and re-runs the migration on the next apply.

Output:

  # null_resource.db_migrate must be replaced
-/+ resource "null_resource" "db_migrate" {
      ~ id       = "8129041..." -> (known after apply)
      ~ triggers = {
          ~ "schema_version" = "a1b2..." -> "c3d4..."
        }
    }

Plan: 1 to add, 0 to change, 1 to destroy.

terraform_data: the modern replacement

terraform_data is a built-in resource (no provider block required, available in Terraform 1.4+ and OpenTofu 1.6+) that serves the same purpose. It exposes a triggers_replace argument and an input/output pair for storing arbitrary values. Provisioners attach to it exactly as they do to null_resource.

resource "terraform_data" "db_migrate" {
  triggers_replace = [
    filemd5("${path.module}/schema.sql"),
    aws_rds_cluster.main.id,
  ]

  provisioner "local-exec" {
    command = "psql -h ${aws_rds_cluster.main.endpoint} -f ${path.module}/schema.sql"
  }
}

Note triggers_replace is a single value (commonly a list or tuple) rather than a map of named keys — any change to the overall value forces replacement.

Storing a computed value

A second use of terraform_data is to persist a value through state so changes can be detected — useful for forcing replacement of another resource on an arbitrary signal. The input argument is copied verbatim to output:

variable "revision" {
  type    = string
  default = "v1"
}

resource "terraform_data" "deploy_marker" {
  input = var.revision
}

resource "aws_instance" "app" {
  ami           = "ami-0abcdef1234567890"
  instance_type = "t3.micro"

  lifecycle {
    replace_triggered_by = [terraform_data.deploy_marker.output]
  }
}

Bumping var.revision changes deploy_marker.output, and replace_triggered_by forces the instance to be recreated — a clean, provider-free way to wire arbitrary replacement triggers.

Comparison

Featurenull_resourceterraform_data
Requires external providerYes (hashicorp/null)No (built-in)
Trigger argumenttriggers (map of strings)triggers_replace (any value)
Stores a valueNoYes (input -> output)
Supports provisionersYesYes
Minimum versionAnyTerraform 1.4 / OpenTofu 1.6

Prefer terraform_data for new code. It drops the external provider dependency, supports value storage, and pairs naturally with the replace_triggered_by lifecycle meta-argument introduced in Terraform 1.2.

Use cases and limits

Both resources shine for: running a one-off script when a config file changes, triggering a deployment step after infrastructure is provisioned, or bridging gaps where no native provider resource exists. The value-storage feature of terraform_data is also handy for surfacing computed data in outputs without a real resource.

The limits are important. Provisioners are a last resort in Terraform — they run only on create/destroy, do not capture or expose results to the graph, and offer no idempotency guarantees. If a local-exec command fails, the resource is tainted but partial side effects on external systems remain. They also break the declarative model: Terraform cannot reconcile what the script actually did.

Do not use these resources to manage real infrastructure that a provider could manage. A wrapped local-exec calling a CLI gives you none of the drift detection, import, or plan accuracy that a native resource provides. Reach for them only when no provider resource fits.

Best practices

  • Default to terraform_data over null_resource to avoid the external provider dependency.
  • Use file hashes (filemd5, filesha256) in triggers so changes to scripts or templates force a re-run.
  • Keep provisioner commands idempotent — assume they may run again on any replacement.
  • Pair terraform_data with replace_triggered_by instead of hand-rolling tainting logic.
  • Treat provisioners as a last resort; prefer a native provider resource whenever one exists.
  • Avoid placing secrets directly in triggers/triggers_replace, since they are stored in plaintext in state.
Last updated June 14, 2026
Was this helpful?