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
| Feature | null_resource | terraform_data |
|---|---|---|
| Requires external provider | Yes (hashicorp/null) | No (built-in) |
| Trigger argument | triggers (map of strings) | triggers_replace (any value) |
| Stores a value | No | Yes (input -> output) |
| Supports provisioners | Yes | Yes |
| Minimum version | Any | Terraform 1.4 / OpenTofu 1.6 |
Prefer
terraform_datafor new code. It drops the external provider dependency, supports value storage, and pairs naturally with thereplace_triggered_bylifecycle 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-execcalling 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_dataovernull_resourceto 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_datawithreplace_triggered_byinstead 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.