terraform

The import block, and the resources nobody wrote down

The import block, and the resources nobody wrote down

Terraform 1.5 arrived in June with config-driven import and check blocks. The import work is the one I care about, because every AWS account I have worked in contains the same drawer: a security group somebody made in the console during an incident, a bucket that predates the repository, a role attached to a thing nobody can identify.

Those resources are real, they are load-bearing, and they are invisible to Terraform. Adopting them was always possible and never worth the afternoon.

What made it tedious

The old terraform import was a CLI command that mutated state. You ran it against a resource address and an ID, it wrote the object into state, and then you had to write the configuration yourself — from scratch, by reading the console, attribute by attribute.

If you got the configuration wrong, the next plan proposed changing the live resource to match what you had written. On a security group in front of a production database, that is the sort of plan you read four times and then decide to do next quarter.

Three properties made it bad. It was imperative in a declarative tool, so it left no record in the repository. It was one resource per invocation, so forty resources meant forty commands in the right order. And there was no dry run: the import had already happened by the time you found out whether your configuration matched.

What it looks like now

Import is a block in the configuration:

import {
  to = aws_security_group.legacy_db
  id = "sg-0a1b2c3d4e5f67890"
}

That block participates in the normal plan and apply cycle. terraform plan tells you what importing would do before anything happens — including whether your configuration matches the real object, which is the review that was missing.

And you no longer have to write the configuration by hand:

terraform plan -generate-config-out=generated.tf

Terraform reads the live resource and writes HCL for anything named in an import block that has no configuration yet. The output needs editing — it is verbose, it hardcodes values that should be variables or references, and it will include attributes you would rather not pin — but editing generated HCL against a real object is a different job from reconstructing it from a console page.

The workflow that follows is the one I wanted: add import blocks for a batch, generate, read the generated configuration as a diff, tidy it, plan until the diff is empty, apply, then delete the import blocks because they have done their job. It is reviewable, it happens in a pull request, and it is one plan for forty resources instead of forty commands.

check blocks, which are not preconditions

1.5 also adds check blocks, and it is easy to confuse them with the preconditions from 1.2.

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

  assert {
    condition     = data.http.app.status_code == 200
    error_message = "Application health check returned ${data.http.app.status_code}."
  }
}

The difference that matters: a failed precondition halts the run. A failed check produces a warning and lets the run finish. That sounds weaker and it is the point — these are assertions about the world rather than about the configuration, and the world being briefly wrong is not a reason to abandon an apply that is halfway through.

It gives you somewhere to put the checks that previously lived in a smoke-test script running after the pipeline, which is somewhere they were easy to forget.

The honest caveat

Generated configuration is a starting point and it looks like an ending point. That is the trap. It arrives complete, it plans cleanly, and it is very tempting to merge as-is — at which point you have adopted a resource along with every incidental attribute it happened to have, hardcoded, with no variables and no references to the resources it should be pointed at.

The value of importing something is that it becomes reviewable and reproducible. Merging generated HCL unread gets you the first half and not the second: the resource is in state, and the configuration still does not say why it is the way it is.

So the rule I am applying is that generated configuration is a draft of a pull request, not the pull request. Which, given that the alternative was reconstructing it from a console page, is a very good place to start from.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev