Aug 3rd - Importing existing resources into Terraform State with import blocks
In the previous post we walked through migrating a flat Terraform project to modules and used moved blocks to shift existing state to the new addresses without destroying anything. This post uses that same project for an exploration of common situation: what if those resources had never been managed by Terraform at all?
The example code referenced in this post lives here. I recommend having it open as you read.
It is not unusual to inherit infrastructure that was created manually, by a different team, or before anyone got around to or even thought of writing Terraform for it. The DynamoDB table exists, the Lambda functions exist, they are running in production, and now you need to bring them under Terraform management without touching them. That is the scenario this post covers.
The old way
Before Terraform 1.5, the way to do this was the terraform import command.
terraform import module.dynamodb.aws_dynamodb_table.this my-project-subscribers
You run it once per resource, manually, from wherever you have credentials. It is not version controlled, it leaves no record in the codebase, and there is no Merge Request/Pull Request to review. If something goes wrong or someone asks how that resource ended up in state, the answer is "someone ran a command at some point." That is not a great answer.
Import blocks
Terraform 1.5 introduced import blocks. Same result, different approach.
import {
to = module.dynamodb.aws_dynamodb_table.this
id = "my-project-subscribers"
}
This goes in a .tf file, gets committed, goes through your normal MR review process, and runs automatically on the next terraform apply. The same arguments made for moved blocks in the previous post apply here: version controlled, reviewable, and the git history tells you exactly what was done and when. You can technically name this file whatever you like, for small scale I would use something like imports.tf but if you had many resources to import one could make the argument to group them in separate files such as lamdbda_imports.tf. Whichever route you decide be kind to future you and fellow engineers by making the naming clear and consistent.
Finding the resource ID
Every resource page in the Terraform AWS provider documentation has an "Import" section at the bottom; assuming it's supported by the version you have selected in the docs. That section shows the exact format the id field expects. Keep in mind that this feature did not always exist so there will be instances where you need to upgrade to a minimum version for the import blocks to be available.
For example, the aws_dynamodb_table import section shows:
terraform import aws_dynamodb_table.example <name>
That tells you the ID is the table name. Check the docs page for whatever resource type you are importing and the format will be there.
For the resources in this example:
aws_dynamodb_table: table nameaws_lambda_function: function nameaws_cloudwatch_log_group: the full log group path (e.g./aws/lambda/my-function)
The provider docs are your reference for the ID format. If you are starting from a flat structure with no existing config, -generate-config-out (covered below) can generate resource blocks from live state and includes the correct ID format in its output.
Importing the DynamoDB table
Using the modular structure from the previous post, the import block targets the resource inside the module. The to address follows the same pattern as a moved block: module.<name>.<resource_type>.<resource_name>.
import {
to = module.dynamodb.aws_dynamodb_table.this
id = "my-project-subscribers"
}
If you were importing this before modularizing, the address would be the flat resource address instead: aws_dynamodb_table.subscribers. The import block does not care either way; it just needs the address to match what is in your config.
Importing a Lambda function
The Lambda module in this project creates two resources: the function and the CloudWatch log group. If both exist in AWS and neither is in state, you need an import block for each.
import {
to = module.subscribe.aws_lambda_function.this
id = "my-project-subscribe"
}
import {
to = module.subscribe.aws_cloudwatch_log_group.this
id = "/aws/lambda/my-project-subscribe"
}
The log group ID is the full path, not just the name. The provider docs for aws_cloudwatch_log_group make that clear.
generate-config-out
Disclaimer this feature is still listed as experimental so you will want to do an in depth review of the output.
Worth knowing about, with an important caveat on where it applies.
terraform plan -generate-config-out=generated.tf
Terraform reads the live resource from AWS and writes a resource block with its current attribute values into generated.tf. If you are importing a resource that has no Terraform config written for it yet, this removes the guesswork of what the config should look like.
The caveat: this only generates flat root-level resource blocks. It does not work with module addresses. In the context of the previous post, you could use -generate-config-out prior to the migration from flat terraform to modules.
Where this could be useful is before you have modules in place. If you are starting from scratch and importing a set of unmanaged resources with no existing config, write the import blocks and run -generate-config-out to get the initial resource definitions from live state. From there you can clean them up, wrap them in modules, and migrate state with moved blocks as covered in the previous post.
generated.tf is a starting point, not a finished product. Do not commit it directly. Clean up computed-only attributes Terraform manages automatically, then move what you need into the right file.
Running through the pipeline
Same approach as the previous post: push the branch with the import blocks, let the pipeline run terraform plan, and review the plan output. Confirm that each import is bringing in the resource with no unexpected destroys or in-place changes that would indicate a config mismatch. Review it the same way you would review any other plan before merging.
A gotcha I have stumbled on, if you try to import a resource to an address that already exists in state, Terraform skips that import and keeps going. It does not error. The plan output will not tell you the import was skipped. This makes having import blocks in your code pretty safe, there will be no modification or destruction of existing resources with a specific address.
Cleanup
After a successful apply, remove the import blocks. The git history is the record. Same reasoning as moved blocks.
Closing thoughts
Import blocks are a straightforward improvement over the CLI command approach. They fit into the same workflow as every other Terraform change: write it, review it, apply it. The barrier to bringing unmanaged resources under Terraform is pretty low.
If there are any parts of this post that need clarification feel free to reach out via one of the contact methods listed on my website!

Keep the coffee flowing