Post

The Terraform `null_resource` Anti-Pattern `terraform_data` Finally Replaces for Private Data

Are you still relying on `null_resource` for temporary data operations in Terraform? Terraform 1.16 renders this entire workflow obsolete.

The Terraform `null_resource` Anti-Pattern `terraform_data` Finally Replaces for Private Data

For years, infrastructure developers hacked around Terraform’s lack of native temporary data management by throwing the null_resource provider at every problem. This approach bloats your .terraform directory, complicates state files, and creates unpredictable execution dependencies. Terraform 1.16 renders this entire workflow obsolete.

TL;DR: The legacy null_resource requires downloading an external provider and fighting implicit lifecycle bugs just to store ephemeral values. Terraform 1.16’s native terraform_data solves this by handling triggers, provisioners, and arbitrary data storage internally without extra binaries. This post outlines the exact syntax changes to rip null_resource out of your codebase today.

What you will walk away with:

  • Identify implicit dependency chains caused by null provider network lag.
  • Convert legacy null_resource provisioners to native terraform_data blocks.
  • Manage sensitive ephemeral values without leaking them to external provider logs.
  • Reduce initial terraform init execution time by removing unnecessary binary downloads.

Why Is null_resource Considered Bad Practice in Terraform?

The legacy null_resource is an anti-pattern because it forces Terraform to download a dedicated external provider binary solely to do nothing. This introduces unnecessary network calls during initialization, fragments your dependency graph, and frequently triggers unintended lifecycle replacements. Upstream attribute fluctuations during the plan phase constantly break these environments.

A null_resource is a dummy provider block historically used as a hack to execute local provisioners or store arbitrary trigger values that don’t map to real infrastructure.

Warning: Relying on hashicorp/null for database initialization scripts at Aicademy caused dozens of failed deployments. The null_resource would trigger before the target database was fully active, lacking the native lifecycle awareness required to pause the run.

The old architecture forced Terraform Core to communicate with an entirely separate binary over RPC just to evaluate an empty string. The modern approach processes the data strictly inside the core engine.

graph TD
    A["Terraform Core (v1.16)"] -->|"RPC Call"| B["Null Provider Binary"]
    B -->|"Returns empty data"| C["tfstate"]
    A -->|"Native handling"| D["terraform_data"]
    D -->|"Writes directly"| C

Avoid bringing external binaries into your graph just to store a temporary string or trigger a local script.

How Do You Migrate From null_resource to terraform_data?

You migrate by replacing the resource block type directly and renaming the legacy triggers map argument to the triggers_replace argument. Because this functionality is built natively into Terraform Core starting in v1.14 and stabilized in v1.16, you immediately delete the hashicorp/null requirement from your provider configurations entirely.

The migration requires a direct syntax translation in your .tf files. The new block supports typed arrays for triggers, meaning you are no longer forced to invent arbitrary string keys for your maps.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
- resource "null_resource" "aicademy_db_init" {
-   triggers = {
-     cluster_id = aws_rds_cluster.main.id
-   }
-   provisioner "local-exec" {
-     command = "./init-schema.sh"
-   }
- }
+ resource "terraform_data" "aicademy_db_init" {
+   triggers_replace = [
+     aws_rds_cluster.main.id
+   ]
+   provisioner "local-exec" {
+     command = "./init-schema.sh"
+   }
+ }

This transition handles sensitive state updates significantly better than external providers. See Is Your Terraform State Hiding Sensitive Data? The Power of terraform_data in v1.16 for a deep dive into state file implications. Swapping this single block removes roughly 15MB of provider binaries from your system. It also shaves several seconds off CI/CD pipeline initializations by eliminating the provider registry API check.

Use tuples [...] instead of maps {...} for triggers_replace to avoid arbitrary key-naming conventions.

What Are the Best Practices for Private Data Lifecycle in Terraform 1.16?

Define explicit replacement triggers using triggers_replace only for values that mandate a full re-execution of your attached provisioners. Store arbitrary ephemeral values in the input attribute instead. This naturally propagates to the output attribute without forcing a destructive resource replacement when the data is modified.

Default to using terraform_data exclusively for orchestrating local provisioners and capturing intermediate computed variables. Never use it to store raw credentials or API keys that belong in a dedicated secret manager. Standardizing on native blocks drastically improves automated AST parsing and policy evaluation. Implementing Manual Policy Reviews vs. Automated tfpolicy Guardrails: Accelerate Compliance by 90% becomes trivial when your configuration avoids external dummy providers.

Follow this checklist to safely update your modules:

  • Remove hashicorp/null from the required_providers block in versions.tf.
  • Rename all null_resource blocks to terraform_data.
  • Convert the triggers map into a triggers_replace array.
  • Run terraform state mv to shift existing state data and avoid triggering accidental destruction.
Feature null_resource terraform_data Winner
Provider Download Required (External) Built-in (Core) terraform_data
Trigger Argument triggers (map) triggers_replace (any type) terraform_data
Data Storage None input and output terraform_data
Compliance Parsing Difficult Native terraform_data

Map computed attributes to the input variable to pass structured object data safely between isolated modules.

How Do You Apply Ephemeral Changes Safely Without Provider Overhead?

Execute your infrastructure updates directly via the CLI, letting the internal engine process the native data blocks directly. As stated in HashiCorp’s apply documentation, running this command without a saved plan file defaults to an implicit interactive plan execution. This ensures your trigger conditions are accurate before committing changes.

Run the apply targeting just the newly converted data block.

1
terraform apply -target="terraform_data.aicademy_db_init"

The output will confirm the native core engine is handling the resource without reaching out to the registry.

1
2
3
4
5
6
7
8
9
10
11
Terraform will perform the following actions:
  # terraform_data.aicademy_db_init will be created
  + resource "terraform_data" "aicademy_db_init" {
      + id               = (known after apply)
      + output           = (known after apply)
      + triggers_replace = [
          + "cluster-12345",
        ]
    }

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

If things break, the error trace from native blocks is significantly cleaner than the old RPC provider panics. You can inspect the execution flow directly in the verbose logs.

View verbose lifecycle execution trace
1
2
3
4
5
2024-05-10T10:00:00.000Z [TRACE] eval: evalAllocateNativeResource
2024-05-10T10:00:00.005Z [TRACE] eval: evalWriteState
2024-05-10T10:00:00.010Z [INFO]  terraform_data.aicademy_db_init: Creating...
2024-05-10T10:00:00.015Z [TRACE] provisioner "local-exec" starting
2024-05-10T10:00:01.000Z [INFO]  terraform_data.aicademy_db_init: Creation complete after 1s

If you enforce deployment checks during applies, ensure your new triggers do not violate existing rules. Review Why Your tfpolicy deny Rule Is Silently Failing in Production (And How to Debug It) if your execution hangs unexpectedly during the provisioner phase.

Always use the -target flag when testing new replacement triggers in isolation before running a full environment apply.

Bottom Line

The days of downloading hashicorp/null to manage temporary data constraints are officially over. Standardize on terraform_data across all v1.16 projects to eliminate provider lag, simplify dependency graphs, and keep ephemeral values strictly within native Terraform execution.

Next up: We will break down how to migrate legacy remote state data blocks into dynamic backend configurations.

FAQ

What is the primary difference between null_resource and terraform_data?

terraform_data is built directly into Terraform Core, eliminating the need to download an external provider plugin. It also supports complex types for its triggers, whereas null_resource only accepts maps of strings.

Does terraform_data support local-exec and remote-exec provisioners?

Yes. You can attach provisioner blocks exactly as you did with null_resource to execute arbitrary scripts locally or over SSH.

How do you safely migrate existing state from null_resource?

Use the terraform state mv command to shift the specific resource target from the old null provider to the new native block. This prevents the unnecessary destruction and recreation of the underlying resource execution history.

Can terraform_data store complex object variables?

Yes. By passing an object, map, or tuple into the input argument, terraform_data captures the exact structure, which can then be referenced downstream via the output attribute.

Why do my terraform_data triggers force replacement on every apply?

This happens if you pass a dynamically computed value, like a timestamp or a random string, directly into the triggers_replace array. Ensure you only reference static IDs or specific upstream resource attributes.

Part of the series: tf-1-16-private-data

  1. Is Your Terraform State Hiding Sensitive Data? The Power of `terraform_data` in v1.16
  2. The Terraform `null_resource` Anti-Pattern `terraform_data` Finally Replaces for Private Data (you are here)
  3. Is Your Terraform State Secure? A 5-Minute Audit for `terraform_data` Usage in v1.16

Further Reading


🚀 Ready to get hands-on? Spin up an interactive AI or Kubernetes Sandbox at Aicademy Labs for free.

This post is licensed under CC BY 4.0 by the author.