Terraform: using import, and some hidden pitfalls

By | 06/15/2025
 

Terraform has two ways to bring existing resources under Terraform management – using the Terraform CLI and the terraform import command, or using the import resource.

Why might we need to import resources?

  • if we already have a manually configured (the “clickops”) service that we want to bring under Terraform management (for example, the common history when it was done as a Proof of Concept, and then went into Production)
  • if we have resources that were created with another IaC system, for example, CloudFormation
  • if we lost our state file and need to restore it
  • or if we split one large project into smaller ones and create new state files

In addition to the Terraform CLI and import block, there are tools like Terraformer and Terracognita that do some of the work themselves – but today we’ll try everything without them.

The process of importing resources

How the import process looks like:

  • describe an empty resource in the tf-file
  • perform terraform import
  • compare the data in the state file and your code
  • transfer changes to the tf-file
  • profit!

Example of importing from Terraform CLI

Let’s create an AWS IAM User:

$ aws --profile setevoy iam create-user --user-name iam-user-to-be-imported
{
    "User": {
        "Path": "/",
        "UserName": "iam-user-to-be-imported",
        "UserId": "AIDAT3EEMW7XERE75PH6N",
        "Arn": "arn:aws:iam::264***286:user/iam-user-to-be-imported",
        "CreateDate": "2025-06-14T12:15:52+00:00"
    }
}

Create a test Terraform project:

terraform {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.0"
    }
  }
}

provider "aws" {
  profile = "setevoy"
  region  = "us-east-1"
}

Run terraform init:

$ terraform init
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/aws versions matching "~> 5.0"...
- Installing hashicorp/aws v5.100.0...
- Installed hashicorp/aws v5.100.0 (signed by HashiCorp)
...

Importing AWS IAM User

Describe a block for the IAM user that we will be transferring to Terraform. In this case, it will be aws_iam_user:

...
resource "aws_iam_user" "imported_iam_user" {
    # Import this user using the command:
    # terraform import aws_iam_user.imported_iam_user iam-user-to-be-imported
    name = "iam-user-to-be-imported"
}

About the parameters to be specified in our “template”:

  • if it was, for example, an S3 bucket, then all parameters are optional for it, and it was enough to just specify the resource "aws_s3_bucket" "example" {}
  • but aws_iam_user has a required name parameter

Therefore, here we set the required parameter name – this is enough.

Now we can import the user from AWS by specifying the name of the resource in the code (its identifier for Terraform) – aws_iam_user.imported_iam_user, and the name of the user in AWS IAM:

$ terraform import aws_iam_user.imported_iam_user iam-user-to-be-imported
aws_iam_user.imported_iam_user: Importing from ID "iam-user-to-be-imported"...
aws_iam_user.imported_iam_user: Import prepared!
  Prepared aws_iam_user for import
aws_iam_user.imported_iam_user: Refreshing state... [id=iam-user-to-be-imported]

Import successful!

Let’s check the Terraform state:

$ terraform state list 
aws_iam_user.imported_iam_user

And the contents of the aws_iam_user.imported_iam_user object in this state:

$ terraform state show aws_iam_user.imported_iam_user
# aws_iam_user.imported_iam_user:
resource "aws_iam_user" "imported_iam_user" {
    arn                  = "arn:aws:iam::264***286:user/iam-user-to-be-imported"
    id                   = "iam-user-to-be-imported"
    name                 = "iam-user-to-be-imported"
    path                 = "/"
    permissions_boundary = null
    tags                 = {}
    tags_all             = {}
    unique_id            = "AIDAT3EEMW7XERE75PH6N"
}

Next, we can update our code, but there is a nuance.

No changes. Your infrastructure matches the configuration.

Now here’s an interesting thing: if you execute terraform plan now, Terraform will tell you that no changes need to be made:

$ terraform plan 
aws_iam_user.imported_iam_user: Refreshing state... [id=iam-user-to-be-imported]

No changes. Your infrastructure matches the configuration.

Although, it would seem that our code does not describe any user attributes that we see in the terraform state show.

The reason is that when we created an IAM user with the AWS CLI and the create-user command, we did not specify any additional options, and the AWS CLI created it with all the default parameters via the AWS API.

Terraform does the same thing when we run terraform plan – it checks the values in AWS against the values described in the provider, sees that everything is default, and therefore says that no changes need to be made.

As an example, the default value of path is explicitly set in the provider as "/" – see internal/service/iam/user.go#L62:

...
      names.AttrPath: {
        Type:     schema.TypeString,
        Optional: true,
        Default:  "/",
      },
...

Now let’s create a new user, but with an explicitly specified path:

$ aws --profile setevoy iam create-user --user-name iam-user-to-be-imported-2 --path /some-path/

Add it to the code like the first user, without additional parameters:

...
resource "aws_iam_user" "imported_iam_user_2" {
    # Import this user using the command:
    # terraform import aws_iam_user.imported_iam_user iam-user-to-be-imported
    name = "iam-user-to-be-imported"
}

Run terraform import:

$ terraform import aws_iam_user.imported_iam_user_2 iam-user-to-be-imported-2

And let’s look at the result of terraform plan again – this time, Terraform will want to change the user’s path attribute:

$ terraform plan 
...
  # aws_iam_user.imported_iam_user_2 will be updated in-place
  ~ resource "aws_iam_user" "imported_iam_user_2" {
      + force_destroy        = false
        id                   = "iam-user-to-be-imported-2"
        name                 = "iam-user-to-be-imported-2"
      ~ path                 = "/some-path/" -> "/"
        tags                 = {}
        # (4 unchanged attributes hidden)
    }

Updating main.tf

Okay, let’s move on.

We’ve done the import – we have the user in the state file, and we have the code in main.tf – the “empty template” of this user.

To complete the import, we need to bring our code to the same state as in the state, because our code must be the source of truth for this resource.

At the same time, we do not need to transfer absolutely all parameters from the Terraform state to the resource in the code: we transfer only what we want to explicitly control or if we want to display them in the code for clarity.

There are parameters for which default values are set, and there are parameters that are generated by AWS itself.

  • Terraform resource configuration parameters: name, path, tags, etc
    • for most of them, there are default values
  • generated parameters: arn, unique_id (UserId in AWS CLI outputs)

The configuration parameters can be found in the resource documentation in the Argument Reference.

The parameters generated by AWS itself are described in the Attribute Reference.

Another way to determine this is to look at the provider’s code, for example, for unique_id, the value is specified in internal/service/iam/user.go#L82:

...
      "unique_id": {
        Type:     schema.TypeString,
        Computed: true,
      },
...

Here, in the Computed field, we see that it is determined automatically by AWS.

So in this case, we definitely need to set the path:

...
resource "aws_iam_user" "imported_iam_user_2" {
    name = "iam-user-to-be-imported-2"

    path = "/some-path/"
}

Execute the terraform plan – and now there are no changes:

$ terraform plan 
aws_iam_user.imported_iam_user_2: Refreshing state... [id=iam-user-to-be-imported-2]
aws_iam_user.imported_iam_user: Refreshing state... [id=iam-user-to-be-imported]

No changes. Your infrastructure matches the configuration.

Terraform diffing mechanism

Now, here’s another interesting point.

What if we set some additional attributes to our already imported user, such as tags:

resource "aws_iam_user" "imported_iam_user_2" {
    name = "iam-user-to-be-imported-2"

    path = "/some-path/"

    tags = {
        ManagedBy   = "Terraform"
    }
}

And run the terraform plan again:

$ terraform plan 
...
Terraform will perform the following actions:

  # aws_iam_user.imported_iam_user_2 will be updated in-place
  ~ resource "aws_iam_user" "imported_iam_user_2" {
      + force_destroy        = false
        id                   = "iam-user-to-be-imported-2"
        name                 = "iam-user-to-be-imported-2"
      ~ tags                 = {
          + "ManagedBy" = "Terraform"
        }
...

Then we see an interesting thing: this time, Terraform wants to add the force_destroy = false attribute.

Why is this?

Because force_destroy is an attribute that exists only in Terraform’s code, but it does not exist in AWS: when performing terraform import, Terraform from the AWS API receives the attributes provided by AWS and saves them in its state.

Accordingly, there is no force_destroy in the state now:

$ terraform state show aws_iam_user.imported_iam_user_2
# aws_iam_user.imported_iam_user_2:
resource "aws_iam_user" "imported_iam_user_2" {
    arn                  = "arn:aws:iam::264***286:user/some-path/iam-user-to-be-imported-2"
    id                   = "iam-user-to-be-imported-2"
    name                 = "iam-user-to-be-imported-2"
    path                 = "/some-path/"
    permissions_boundary = null
    tags                 = {}
    tags_all             = {}
    unique_id            = "AIDAT3EEMW7XER5THQH4Q"
}

When Terraform executes the plan – in the first case, when we set only the name and path values :

  • during the plan execution, Terraform performs a “quick check” – compares the arguments in the code with the data in the state
  • sees that no changes have been made, and then exits with the message “Your infrastructure matches the configuration

The second case – we added tags, and then:

  • during the plan execution – Terraform performs a “quick check” – compares the arguments in the code with the data in the state
  • Terraform sees that some resource attributes have changed – and starts performing a more detailed check, generating a complete resource schema with all the defaults
  • Terraform sees that there is no force_destroy argument in the state file and plans to add it to the state

Actually, since force_destroy has a default value of false, we can simply execute terraform apply, and a new attribute will appear in the state:

$ terraform state show aws_iam_user.imported_iam_user_2
# aws_iam_user.imported_iam_user_2:
resource "aws_iam_user" "imported_iam_user_2" {
    arn                  = "arn:aws:iam::264***286:user/some-path/iam-user-to-be-imported-2"
    force_destroy        = false
    ...
    permissions_boundary = null
    tags                 = {
        "ManagedBy" = "Terraform"
    }
...

Importing to a module

In addition to importing objects as regular Terraform resources, we can add them to modules.

For example, Anton Babenko’s module terraform-aws-modules/iam, which has a submodule iam-user.

Let’s create a “template” in our main.tf:

...
module "iam_user_imported" {
  source = "terraform-aws-modules/iam/aws//modules/iam-user"

  name = "iam-user-to-be-imported-2"
  path = "/some-path/"
}

Find how the resource is set in the module itself – the file modules/iam-user/main.tf:

resource "aws_iam_user" "this" {
  count = var.create_user ? 1 : 0

  name                 = var.name
  path                 = var.path
  force_destroy        = var.force_destroy
  permissions_boundary = var.permissions_boundary

  tags = var.tags
}

Now, we can import our user using the module.iam_user_imported.aws_iam_user.this identifier.

But.

Error “Configuration for import target does not exist”

When we start the import, we’ll face with an error:

$ terraform import module.iam_user_imported.aws_iam_user.this iam-user-to-be-imported-2
╷
│ Error: Configuration for import target does not exist
│ 
│ The configuration for the given import module.iam_user_imported.aws_iam_user.this does not exist. All target instances must have an associated configuration to be imported.

Why?

Because let’s go back to the module’s code:

... 
count = var.create_user ? 1 : 0 
...

When using count, Terraform creates a list with elements, even if there is only one.

That is, the condition says: “if var.create_user == true, then create one object” – but this will be a list object with one element.

Therefore, the resource must be accessed by the index – [0]:

$ terraform import module.iam_user_imported.aws_iam_user.this[0] iam-user-to-be-imported-2
module.iam_user_imported.aws_iam_user.this[0]: Importing from ID "iam-user-to-be-imported-2"...
module.iam_user_imported.aws_iam_user.this[0]: Import prepared!
  Prepared aws_iam_user for import
module.iam_user_imported.aws_iam_user.this[0]: Refreshing state... [id=iam-user-to-be-imported-2]

And now it is in our state:

$ terraform state show module.iam_user_imported.aws_iam_user.this[0]
# module.iam_user_imported.aws_iam_user.this[0]:
resource "aws_iam_user" "this" {
    arn                  = "arn:aws:iam::264***286:user/some-path/iam-user-to-be-imported-2"
    id                   = "iam-user-to-be-imported-2"
    name                 = "iam-user-to-be-imported-2"
    path                 = "/some-path/"
    permissions_boundary = null
    tags                 = {
        "ManagedBy" = "Terraform"
    }
    tags_all             = {
        "ManagedBy" = "Terraform"
    }
    unique_id            = "AIDAT3EEMW7XER5THQH4Q"
}

Using Terraform import block

And let’s see how the import works in the code itself.

Basically, everything is the same here – we specify what (id) and where (to) to import:

import {
  to = aws_iam_user.imported_iam_user_3
  id = "iam-user-to-be-imported-2"
}

resource "aws_iam_user" "imported_iam_user_3" {
  name = "iam-user-to-be-imported-2"

  path = "/some-path/"

  tags = {
    ManagedBy = "Terraform"
  }
}

Now when we execute terraform plan, we will see what exactly and with what parameters will be imported:

$ terraform plan 
aws_iam_user.imported_iam_user_3: Preparing import... [id=iam-user-to-be-imported-2]
aws_iam_user.imported_iam_user_3: Refreshing state... [id=iam-user-to-be-imported-2]
aws_iam_user.imported_iam_user: Refreshing state... [id=iam-user-to-be-imported]
...

Terraform will perform the following actions:

  # aws_iam_user.imported_iam_user_3 will be imported
    resource "aws_iam_user" "imported_iam_user_3" {
        arn                  = "arn:aws:iam::264***286:user/some-path/iam-user-to-be-imported-2"
        id                   = "iam-user-to-be-imported-2"
        name                 = "iam-user-to-be-imported-2"
        path                 = "/some-path/"
        permissions_boundary = null
        tags                 = {
            "ManagedBy" = "Terraform"
        }
        tags_all             = {
            "ManagedBy" = "Terraform"
        }
        unique_id            = "AIDAT3EEMW7XER5THQH4Q"
    }

Plan: 1 to import, 0 to add, 1 to change, 0 to destroy.
...

Done.