Terraform Import, State Inspection, and Debug Logging
Importing brings infrastructure that already exists - created in a console, by a script, or by another tool - under Terraform management by recording it in state, so Terraform manages it in place instead of recreating it. There are two ways to do it: the classic terraform import CLI command, which requires you to write the resource block first and updates only state, and the import block, which makes import a plannable, reviewable part of the normal workflow and can generate configuration for you with -generate-config-out. Once resources are managed, you inspect them with terraform state list, terraform state show, terraform show, terraform state pull and terraform output, and you debug failures with verbose logging through TF_LOG, TF_LOG_PATH and the core- and provider-scoped variants. This lesson covers Terraform Associate 004 objective 7: importing, inspecting state from the CLI, and when and how to use verbose logging.
On this page7 sections
- Import existing resources with terraform import, including count and for_each instance addresses
- Use idempotent import blocks for plannable import, including for_each bulk import
- Generate and repair starter configuration for imported resources with -generate-config-out
- Choose between terraform state list, state show, show, state pull and output for an inspection task
- Enable verbose logging with TF_LOG and send it to a file with TF_LOG_PATH
- Scope log output to Terraform core or providers with TF_LOG_CORE and TF_LOG_PROVIDER
terraform import brings existing resources under management
The terraform import command records an existing real-world object in Terraform state so Terraform manages it in place from then on, without recreating it. Its syntax is terraform import ADDRESS ID: ADDRESS (first) is a managed resource address in your configuration, such as aws_s3_bucket.logs, and ID (second) is the provider-specific identifier of the real object - an instance ID for aws_instance, a bucket name for aws_s3_bucket, a path such as projects/P/zones/Z/instances/N for many Google resources. Each provider's documentation lists the ID format each resource type expects. Data sources cannot be import targets: they are read every run, never managed.
The rule to remember: you must write the resource block before you import. The CLI command refuses an address that is not declared in configuration, and it updates state only - immediately, without a plan - and never writes or edits your .tf files. The workflow is therefore: write the block, import, then run terraform plan and adjust the block's arguments until the plan is clean.
resource "aws_s3_bucket" "logs" {
bucket = "acme-log-archive"
}
terraform import aws_s3_bucket.logs acme-log-archive
terraform plan # reconcile config with the imported attributesA few mechanics matter in practice:
- One object per run. Each invocation imports a single resource instance.
- Instance addresses. For a resource using
countorfor_each, import into one instance:aws_instance.web[0]oraws_iam_role.svc["ci"]. In a POSIX shell, wrap afor_eachaddress in single quotes so the double quotes reach Terraform:terraform import 'aws_iam_role.svc["ci"]' ci-deployer. - Provider configuration. The command reads the real object through the provider, so credentials must be available, and the provider configuration it uses may depend only on input variables, environment variables or literals - not on data sources or other non-variable inputs. If any provider argument - a role ARN, an endpoint, a project ID - is computed from a data source or another resource's attribute, plan and apply still work but
terraform importfails; supply that value through an input variable (or environment variable) instead. - Locking. It acquires the state lock like any state-writing operation.
If the plan after import still shows changes, your configuration does not yet match reality - keep editing the block, not the state. A moved block is not an alternative: it renames addresses of objects Terraform already manages and cannot adopt an unmanaged object.
Config-driven import: the import block
The import block, available since Terraform 1.5 and the recommended approach today, turns import into part of the normal plan/apply cycle instead of a one-off CLI action. You declare in configuration which object goes to which address, and the import happens - visibly - when the plan is applied:
import {
to = aws_instance.web
id = "i-0a1b2c3d4e5f67890"
}
resource "aws_instance" "web" {
ami = "ami-0abcd1234example"
instance_type = "t3.micro"
}terraform import (CLI) | import block | |
|---|---|---|
| When state changes | Immediately, when the command runs | At terraform apply, after the import is shown in the plan |
| Review | None - an ad-hoc command | Plan output and code review, like any change |
| How many objects | One per run | Many, with for_each |
| Writes configuration | Never; block must exist first | Only with -generate-config-out (next section) |
The id argument accepts an expression (a variable or local, for example), not only a literal string, and its format is the same provider-specific ID the CLI command takes.
Bulk import (Terraform 1.7 and later) pairs the import block's for_each with the resource's. Iterating the same map, each.key builds the instance address and each.value supplies the provider ID:
variable "buckets" {
default = { logs = "acme-logs", assets = "acme-assets" }
}
import {
for_each = var.buckets
to = aws_s3_bucket.this[each.key]
id = each.value
}
resource "aws_s3_bucket" "this" {
for_each = var.buckets
bucket = each.value
}Import blocks are idempotent. Once the target is in state, a leftover import block plans no further action - no error, no re-import. You may delete it after the apply or keep it as a record of where the resource came from. Like the CLI command, an import block never changes the real object; it only records it in state.
Generating configuration with -generate-config-out
The -generate-config-out flag writes configuration for objects you are importing but did not create. When a plan contains import blocks whose targets have no corresponding resource block, Terraform reads each object through the provider and writes a best-effort resource block for it into the file you name:
import {
to = google_compute_instance.legacy
id = "projects/acme-prod/zones/us-central1-a/instances/legacy-vm"
}
terraform plan -generate-config-out=generated.tfThree behaviours to know:
- Only configless targets. Import targets that already have a resource block are not generated.
- The file must be new. Terraform errors if the path already exists - including the file from your previous run. Move the reviewed output into your configuration (or pick another file name) before generating for additional import blocks.
- The output needs review. It is a starting point: prune computed or redundant attributes and replace hard-coded values with variables and references. Generated blocks can also contain mutually exclusive arguments, which makes validation fail; keep the one you want and re-plan.
Once the plan is clean, apply to complete the import. Keep the division of labour straight: terraform import needs hand-written configuration and touches only state; the import block plus -generate-config-out is the only path where Terraform writes configuration for you.
Inspecting state from the CLI
Five read-only commands cover state inspection, and each answers a different question:
| Command | Shows | Typical use |
|---|---|---|
terraform state list | Resource addresses in state, one per line - no attributes | What is Terraform managing here? Which address manages this ID? |
terraform state show ADDRESS | Every recorded attribute of one resource instance | What IP/ARN/ID did this resource get? |
terraform show [FILE] | The whole current state, or a saved plan or state file you name; -json for tools | Full snapshot; reviewing a plan artifact |
terraform state pull | The raw state document from the configured backend, on stdout | Backing up or inspecting remote state |
terraform output [NAME] | Root module output values | Consuming declared outputs, including in scripts |
terraform state list can be filtered by address, and -id=ID does a reverse lookup: given a provider ID, it prints the address of the resource that manages that object. terraform state show zooms into a single instance:
terraform state list terraform state list module.network terraform state list -id=i-0a1b2c3d4e5f67890 terraform state show google_compute_instance.vm
terraform state pull downloads the current state through whichever backend is configured and prints it in the raw state-file format, so terraform state pull > backup.tfstate is a common pre-refactor backup. If the stored file uses an older state-file version, the printed copy is upgraded to the newest version the installed Terraform supports. All of these commands read state without modifying it; the commands that change state (state mv, state rm, state push) are covered in the state-management lesson.
terraform show and terraform output
terraform show renders a file in human-readable form: with no argument, the current state; given a path, a saved plan file or a state file. It takes a file path, not a resource address - for one resource, use terraform state show ADDRESS. With -json it emits HashiCorp's documented machine-readable format, the stable interface for policy checks, CI gates and other tooling:
terraform plan -out=tfplan terraform show tfplan # human-readable review of the saved plan terraform show -json tfplan # machine-readable, for automation
Two near-twins trip people up:
| Command | What it actually does |
|---|---|
terraform show -json tfplan | Reads the saved plan and prints it in the documented JSON format |
terraform plan -json | Runs a new plan and streams machine-readable progress; it does not read a saved plan |
terraform show -json | The current state as a JSON representation designed for tools |
terraform state pull | The raw state file itself - Terraform's internal storage format, not the integration format |
So integrate tools with show -json, and use state pull when you need the actual state document, such as a restorable backup.
terraform output prints the root module's output values from state:
terraform output # all outputs; sensitive ones redacted terraform output instance_ip # one value, as an HCL-style literal (strings quoted) terraform output -raw instance_ip # bare string for shell substitution terraform output -json # all outputs as JSON for tools
-raw prints a string, number or boolean with no quotes or formatting, which is what curl "$(terraform output -raw api_url)" needs; it works only for primitive values, so use -json for lists, maps and objects. Outputs marked sensitive = true are redacted in the all-outputs listing, but naming the output, or using -json or -raw, prints the real value in plain text. Sensitivity controls display; it is not encryption, and the value is stored in plain text in state - one reason state storage must be protected.
Verbose logging with TF_LOG
Verbose logging is enabled with the TF_LOG environment variable - there is no CLI flag for it. Setting it to any value turns logging on, and detailed logs appear on stderr; the value chooses the verbosity. The documented levels, from most to least verbose:
| Value | What you get |
|---|---|
TRACE | Everything: internal calls, provider RPCs, raw API request/response detail |
DEBUG | Detailed diagnostics without the full trace |
INFO | High-level progress of operations |
WARN | Potential problems only |
ERROR | Errors only |
JSON | TRACE-level output in a parseable JSON encoding |
export TF_LOG=DEBUG terraform apply TF_LOG=TRACE terraform plan # one-off, without exporting
TF_LOG=JSON suits log platforms that ingest structured lines, but HashiCorp states that the JSON log encoding is not a stable interface and may change without warning, so parsers may need updating between versions. If TF_LOG holds something that is not a level name, current Terraform falls back to TRACE and prints a warning - but that is implementation behaviour, so set a real level name.
When to use it: verbose logging is for reproducing a failure you cannot explain from normal output - an opaque provider error, unexpected plan behaviour, a crash - and for capturing a TRACE log to attach to a bug report against Terraform or a provider. Because TRACE logs include provider API traffic, they can contain sensitive values: enable logging for the reproduction, turn it off afterwards, and review or redact the captured output before sharing it.
Targeting logs: TF_LOG_PATH, TF_LOG_CORE, and TF_LOG_PROVIDER
TF_LOG_PATH sends log output to the file you name, appending to it, instead of writing it to stderr. The catch: TF_LOG_PATH does nothing by itself - HashiCorp's debugging docs state that logging must be enabled with TF_LOG even when TF_LOG_PATH is set. (Logs enabled through the scoped variables below are written to the same file.)
export TF_LOG=TRACE export TF_LOG_PATH=./terraform.log terraform apply # full trace appended to ./terraform.log
When a TRACE log is overwhelming, scope it. TF_LOG_CORE sets a level for Terraform core only (graph building, state handling, plan logic), and TF_LOG_PROVIDER sets a level for provider plugins only (the API calls to AWS, Google Cloud, Azure and so on). Both take the same level names as TF_LOG and activate only their subset of the logs.
| Settings | Core logs | Provider logs |
|---|---|---|
TF_LOG=TRACE | TRACE | TRACE |
TF_LOG_PROVIDER=TRACE (TF_LOG unset) | Off | TRACE |
TF_LOG_CORE=TRACE (TF_LOG unset) | TRACE | Off |
TF_LOG=ERROR + TF_LOG_PROVIDER=DEBUG | ERROR | DEBUG |
TF_LOG=TRACE + TF_LOG_CORE=ERROR | TRACE | TRACE |
The two scoped variables do not combine with TF_LOG the same way. TF_LOG_PROVIDER overrides TF_LOG for providers, so you can raise (or lower) provider detail while core keeps the general level - in the fourth row, providers log at DEBUG while core stays at ERROR. TF_LOG_CORE takes effect only when TF_LOG is unset: in Terraform's logging code, a set TF_LOG wins for core, which is why the last row still logs core at TRACE. To get core-only output, set TF_LOG_CORE and leave TF_LOG unset.
Choosing between them is the practical skill. A resource failing with an opaque API error is a provider-side question (TF_LOG_PROVIDER=TRACE shows the actual requests and responses), while confusion about dependency ordering or state handling is core-side. For a bug report, a TRACE log captured with TF_LOG_PATH is the standard supporting evidence.
Tip. Expect questions on the terraform import workflow - write the managed resource block, import with ADDRESS then ID, then plan until clean - including for_each instance addresses and why provider configuration that depends on a data source blocks the CLI import. The import block versus CLI distinction is tested: plannable, applied at apply time, for_each bulk import, idempotent leftover blocks, and -generate-config-out as the only config-generating path (new file only, conflicting generated arguments). Command-selection questions ask you to pick among terraform state list (including -id), state show, show (state or saved plan, -json for tools), state pull and output (-raw, -json, sensitive redaction). Logging questions cover TF_LOG levels including JSON, TF_LOG_PATH requiring TF_LOG, and how TF_LOG_CORE and TF_LOG_PROVIDER interact with TF_LOG.
- terraform import ADDRESS ID writes state immediately and only state; ADDRESS must be a managed resource block that already exists, and ID is the provider-specific identifier its docs define - it never generates or edits .tf files.
- Import into count/for_each instances with addresses like aws_iam_role.svc["ci"] (single-quoted in a POSIX shell); the provider configuration used by the CLI import cannot depend on data sources or other resources.
- Import blocks are plannable, apply the import at terraform apply, support for_each bulk import from Terraform 1.7 (to = res[each.key], id = each.value) and are idempotent - a leftover block plans nothing.
- terraform plan -generate-config-out=FILE writes HCL only for import targets without a resource block; the file must not exist, and generated mutually exclusive arguments must be pruned before planning again.
- state list = addresses (-id= for reverse lookup); state show ADDRESS = one resource's attributes; show FILE = whole state or a saved plan; state pull = the raw state file; output = root outputs.
- terraform show -json is the documented integration format for tools; terraform plan -json runs a new plan rather than reading a saved one.
- terraform output -raw prints a bare primitive for scripts; sensitive outputs are redacted only in the all-outputs listing and print in plain text when named or with -json/-raw.
- TF_LOG turns on logging to stderr (TRACE, DEBUG, INFO, WARN, ERROR, or JSON); TF_LOG_PATH chooses a file but enables nothing; TF_LOG_PROVIDER overrides TF_LOG for providers, while TF_LOG_CORE applies only when TF_LOG is unset. Verbose logs can hold sensitive data - redact before sharing.
Frequently asked questions
Does terraform import create the configuration for me?
No. The terraform import CLI command only records the object in state, and it fails if the target resource address is not already declared in configuration. The only way Terraform generates configuration is the config-driven path: declare an import block with no matching resource block and run terraform plan -generate-config-out=FILE (a file that does not exist yet), then review and fix the generated HCL before applying.
When should I use an import block instead of the terraform import command?
Prefer the import block for anything reviewed or repeated: it shows the import in terraform plan output so it can go through code review, it supports for_each for importing many objects at once, and it runs in CI like any other change. The CLI command changes state immediately and imports one object per run, so it suits a quick one-off import on a resource whose block you have already written.
What happens if I leave an import block in my configuration after applying it?
Nothing harmful. Import blocks are idempotent: once the target resource is in state, later plans generate no further import action and no error. You can delete the block after the apply or keep it as a record of where the resource came from.
How do I see every attribute Terraform recorded for one resource?
Run terraform state show followed by the resource address, for example terraform state show aws_instance.web. It prints every attribute stored in state for that single instance. Use terraform state list to find the exact address (or terraform state list -id=ID to find it from a provider ID), and terraform show for the entire state or a saved plan file.
What is the difference between terraform state pull and terraform show -json?
terraform state pull prints the raw state file from the configured backend to stdout, which makes it suitable for a backup you could restore; it may upgrade the state to the newest state-file version the installed Terraform supports. terraform show -json prints a separate JSON representation of the state (or of a saved plan) that HashiCorp documents as the stable format for external tools.
Why does terraform output print my sensitive output in plain text?
Because sensitive = true only redacts the value in plan and apply output and in the bare terraform output listing of all outputs. Requesting the output by name, or using -json or -raw, prints the real value. The value is also stored in plain text in the state file, so protect state storage rather than relying on the sensitive flag.
Why is TF_LOG_PATH not producing a log file?
Because TF_LOG_PATH only chooses where enabled logs are appended - it does not enable logging. Set TF_LOG to a level such as TRACE (or use TF_LOG_CORE / TF_LOG_PROVIDER) alongside TF_LOG_PATH and run the failing command again; the log is then appended to the file you named instead of being written to stderr.
What is the difference between TF_LOG_CORE and TF_LOG_PROVIDER?
They scope verbose logging to the two halves of Terraform. TF_LOG_CORE sets the level for Terraform core - graph construction, plan and state logic - and TF_LOG_PROVIDER sets it for provider plugins, including their API requests. Used alone, each enables only its own half. With TF_LOG also set, they behave differently: TF_LOG_PROVIDER overrides TF_LOG for providers (TF_LOG=ERROR with TF_LOG_PROVIDER=DEBUG gives debug-level provider logs and error-level core logs), while TF_LOG_CORE applies only when TF_LOG is unset, because a set TF_LOG takes precedence for core.
Source
This lesson covers the "Maintain infrastructure with Terraform" domain of the official Terraform 004 exam guide. Vendors revise their guides — check the source for the current version.
- HashiCorp Terraform Associate certification page — HashiCorp
Sign up free to mark lessons complete, bookmark topics and track your exam readiness.