A Terraform check block is a warning: precondition, postcondition and check compared

17 August 2026

Terraform check and precondition Generator A failed check does not stop the apply. Runs in your browser.

Terraform has three ways to assert that something is true, and only two of them stop anything. A failed check block produces a warning and the apply succeeds. That is the opposite of what almost everyone assumes when they add one, and it means the guard is in the code, visible in review, and doing nothing.

The three, and what each one does

BlockWhere it livesOn failureCan see the created object
preconditionlifecycle inside a resource or data sourceStops the plan or applyNo
postconditionlifecycle inside a resource or data sourceStops the applyYes, through self
checkTop level of a moduleWarning only. The run succeedsYes, through its own data source

The distinction is not about severity. It is about what you want to happen to the run, and the answer is different for an assumption than for an observation.

A precondition is a claim about the inputs: this AMI must be for the right architecture, this subnet must be in the right VPC, this variable combination must make sense. If it is false, creating the resource is a mistake and the run should not proceed.

A check block is a claim about the world: the health endpoint responds, the certificate has more than 30 days left, the DNS record resolves. If it is false you want to know, and you almost certainly do not want your infrastructure pipeline to refuse to deploy an unrelated change because a certificate is expiring next week.

That is the whole design. The problem is that check reads like the strongest of the three and is the weakest.

What each one looks like

resource "aws_instance" "app" {
  ami           = data.aws_ami.app.id
  instance_type = var.instance_type

  lifecycle {
    precondition {
      condition     = data.aws_ami.app.architecture == "arm64"
      error_message = "The AMI must be arm64 to match the instance type."
    }

    postcondition {
      condition     = self.public_ip == ""
      error_message = "This instance must not receive a public IP."
    }
  }
}

check "endpoint_is_healthy" {
  data "http" "app" {
    url = "https://${aws_lb.app.dns_name}/healthz"
  }

  assert {
    condition     = data.http.app.status_code == 200
    error_message = "The load balancer is not serving 200 on /healthz."
  }
}

Run this with a broken health endpoint and terraform apply reports:

Warning: Check block assertion failed
  The load balancer is not serving 200 on /healthz.

Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

Both lines are printed. The exit code is 0.

Why self does not work in a precondition

This is the second thing people hit, usually within an hour of the first.

A precondition is evaluated before Terraform creates or changes the object. That is exactly why it can stop the work: there is nothing to undo yet. It is also why it cannot look at the result, because the result does not exist. Referring to self there is an error, and referring to the resource by its own address is a dependency cycle.

A postcondition runs after, so self.attribute is available and the object it describes is real. The cost is that by the time it fails, the resource has been created. Terraform marks the run as failed and the object stays in state. A postcondition is a tripwire, not a gate.

The practical rule:

  • Checking an input or another resource? Precondition.
  • Checking the thing you just made? Postcondition, through self.
  • Checking the outside world, and willing to proceed? Check block.

How this shows up in production

The guard nobody noticed was decorative. A check block asserting that encryption is enabled on a bucket, added during a compliance push, passing review, and warning quietly through eleven applies while the bucket was unencrypted. Warnings in CI output scroll past. Nothing in the pipeline treats them as failure because Terraform does not.

The postcondition that fails after the damage. A postcondition on a security group asserting no rule allows 0.0.0.0/0 on port 22. It fires after the rule exists. The run fails, the alert goes out, and the port is open for however long it takes someone to respond. A precondition on the variable that produced the rule would have stopped it before the API call.

The plan that started failing for an unrelated reason. A top-level data "http" probing a service, added to support a condition. Six months later that service has a bad afternoon, and every plan in the repository fails, including ones that touch nothing near it. A data source that cannot be read fails the plan. The same data source inside a check block produces a warning and the plan continues, which is the actual reason the block can contain one.

The wrong instinct: make the check block fail the run

Once you discover a check is only a warning, the reflex is to find the setting that makes it fatal. There is not one, and looking for it is the wrong direction, because it would make the block identical to a precondition and remove the only thing it is good for.

The second reflex is to grep CI output for the word Warning and fail the build on it. This is worse than it sounds. Terraform emits warnings for deprecated arguments, for provider notices, and for legitimate check failures you intended to proceed past. A build that fails on all of them trains everyone to add || true, and then the real ones are invisible again.

The correct move is to decide what you actually meant and use the block that means it:

  • If the run must stop, it was never a check. Move the assertion to a precondition on the resource whose creation you want to prevent.
  • If you want the failure visible but not blocking, keep the check and read the state, not the log. terraform plan -json emits check results as structured events, and terraform show -json includes a checks array with each check’s status. That is a machine-readable signal you can route to monitoring, which is where a non-blocking assertion belongs.

The distinction is worth stating plainly: a check block is a monitor, not a gate. Monitoring output belongs in a monitoring system, not in a build pipeline’s pass/fail.

Trade-offs

ApproachCostWhen it is right
PreconditionFails the run, including runs that were only touching something else in the module. Cannot inspect the resultAssumptions about inputs and about other resources. This is the one to reach for by default
PostconditionFails after creation, so the object exists when you find out. Adds a dependency on the resource being fully knownGuarantees about what was produced, especially attributes the provider computes rather than ones you set
Check blockDoes not stop anything, so it needs somewhere to be read or it is decorationContinuous verification of things outside Terraform’s control. Certificates, endpoints, external DNS
terraform testA separate command and a separate file, so it does not run on applyModule behaviour: what the module does with a given set of inputs. Fixtures rather than production state
Policy as code (OPA, Sentinel)Another tool, another language, and a place to run itOrganisation-wide rules that must not be editable by the module author. A precondition can be deleted by whoever is adding the resource

That last row is the one most often skipped and it matters. A precondition lives in the same file as the thing it constrains, and the person adding a non-compliant resource can remove it in the same commit. If the rule exists so that a team cannot do something, it does not belong in that team’s module.

What changed recently

Version support is the first thing to check, because both errors name the block rather than the version:

  • Preconditions and postconditions: Terraform 1.2. On anything older, a precondition is an unsupported block inside lifecycle.
  • check blocks: Terraform 1.5. On anything older, an unsupported top-level block.
  • terraform test: 1.6, which changed what belongs in a condition at all. Assertions about module behaviour with fixture inputs now have a proper home, so preconditions can go back to being about the real inputs of a real run.
  • Cross-variable validation: 1.9. A variable block’s validation can now reference other variables. A large number of preconditions existed only because validation could not do this, and they can move back to the variable, which is a better place: the error names the variable rather than a resource three files away.

That 1.9 change is worth acting on. A precondition that only reads var.* and nothing else is a validation rule that was in the wrong place because the language could not express it, and moving it up makes the failure message point at the input the caller controls.

Adopting this in a module that already exists

1. Grep for check " and audit every one. For each, ask whether the run should stop. Every “yes” is a misplaced assertion and moves to a precondition.

2. Move input-only preconditions to variable validation. If the condition references nothing but variables and you are on 1.9 or later, it belongs there. The error message improves for free, because it fires at the input.

3. Move top-level probing data sources inside a check. Any data "http", data "external" or similar that exists to observe rather than to configure. This removes a class of plan failure that has nothing to do with your change.

4. Give the surviving check blocks somewhere to be read. Parse terraform show -json for the checks array and send failures to whatever you already use for alerts. A check nobody reads is worse than no check, because it looks like coverage.

5. Write error_message as an instruction. Every one of these blocks fails into somebody’s terminal at an inconvenient moment. “The AMI must be arm64 to match the instance type” is useful. “condition failed” is not, and the condition expression is already printed above it.

When this is the wrong advice

Do not put preconditions on everything. Each one is an expression evaluated on every plan, and a module dense with them is slow to plan and hostile to change. The ones that earn their place guard an assumption that is not obvious from reading the resource.

Do not use a precondition where the type system works. If a variable must be one of four strings, that is a validation block with a contains() check, or in 1.9 and later an object type constraint. A precondition on a resource is a worse version of the same thing, further from the caller.

Do not reach for a check block if you have real monitoring. If Datadog or Prometheus already alerts on that endpoint, a check block duplicates it in a place that only reports during a Terraform run. Continuous verification means verified continuously, and Terraform runs on merge.

The short version

  • A failed check block is a warning. The apply succeeds and the exit code is 0.
  • Preconditions and postconditions stop the run. They are the gates.
  • A precondition runs before the object exists, so self is unavailable. A postcondition runs after and can use self.attribute.
  • A postcondition fails after the resource is created. For anything you must prevent, use a precondition.
  • A data source inside a check block is scoped to it, so its failure warns instead of failing the plan. That is the reason to put it there.
  • Preconditions arrived in 1.2, check blocks in 1.5, terraform test in 1.6, cross-variable validation in 1.9.
  • A precondition can be deleted by whoever is adding the resource. If the rule is organisational, it belongs in policy as code.

The Terraform check and precondition generator produces all three blocks for a resource you name and marks which of them stops a run and which only warns, along with the self rule and the minimum version each needs. It runs entirely in your browser, so the module you paste goes nowhere.