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
| Block | Where it lives | On failure | Can see the created object |
|---|---|---|---|
precondition | lifecycle inside a resource or data source | Stops the plan or apply | No |
postcondition | lifecycle inside a resource or data source | Stops the apply | Yes, through self |
check | Top level of a module | Warning only. The run succeeds | Yes, 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
checkand read the state, not the log.terraform plan -jsonemits check results as structured events, andterraform show -jsonincludes achecksarray 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
| Approach | Cost | When it is right |
|---|---|---|
| Precondition | Fails the run, including runs that were only touching something else in the module. Cannot inspect the result | Assumptions about inputs and about other resources. This is the one to reach for by default |
| Postcondition | Fails after creation, so the object exists when you find out. Adds a dependency on the resource being fully known | Guarantees about what was produced, especially attributes the provider computes rather than ones you set |
| Check block | Does not stop anything, so it needs somewhere to be read or it is decoration | Continuous verification of things outside Terraform’s control. Certificates, endpoints, external DNS |
terraform test | A separate command and a separate file, so it does not run on apply | Module 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 it | Organisation-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. checkblocks: 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
variableblock’svalidationcan 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
checkblock 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
selfis unavailable. A postcondition runs after and can useself.attribute. - A postcondition fails after the resource is created. For anything you must prevent, use a precondition.
- A data source inside a
checkblock 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 testin 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.