# minamijoyo/tfmigrate: treating Terraform state operations as code you can commit

> tfmigrate is a MIT licensed Go tool that lets you write state mv, rm, import and replace-provider operations as HCL migration files, plan and apply them like configuration, and check with a dry run that a migration leaves terraform plan with no changes before the remote state is touched. It shells out to terraform or tofu so it can work across versions, and it keeps a history of which migrations have been applied.

**minamijoyo/tfmigrate** — A Terraform / OpenTofu state migration tool for GitOps

- Repository: https://github.com/minamijoyo/tfmigrate
- Stars: 1,280 · Forks: 64
- Language: Go
- License: MIT
- Published: 2026-10-19 · Updated: 2026-10-19 · Language: en
- Canonical page: https://hysenlabs.com/projects/minamijoyo-tfmigrate

## The problem is state work happening outside version control

tfmigrate is a Terraform and OpenTofu state migration tool for GitOps, and the Why section of the README explains the pain better than the feature list does.

If you have been using Terraform in production for a long time, tfstate manipulations are unavoidable for various reasons. The `terraform state` command is your friend, but it is error-prone and not suitable for a GitOps workflow.

Then comes the specific jam. In team development, Terraform configurations are generally managed by git and states are shared via remote state storage which is outside of version control, which is a best practice for Terraform. However, most Terraform refactorings require not only configuration changes but also state operations such as `state mv`, `rm` and `import`. And it is not desirable to change the remote state before merging the configuration changes, because your colleagues may be working on something else and your CI/CD pipeline continuously plans and applies changes automatically. At the same time you probably want to check that `terraform plan` has no changes after the migration before merging the configuration changes.

So the answer the project gives is direct. We should commit all terraform state operations to git, which brings a new paradigm, to say that Terraform state operation is code.

That framing is what makes the tool interesting. It is not a wrapper that makes `terraform state mv` nicer. It moves the operation into the same review process, the same pull request and the same merge order as the configuration change it accompanies.

## The migration file is HCL, and it lives in git

The whole tool is visible in one small file. You write a migration file, save it as `state_mv.hcl`, and apply it.

```hcl
migration "state" "test" {
  dir = "dir1"
  actions = [
    "mv aws_security_group.foo aws_security_group.foo2",
    "mv aws_security_group.bar aws_security_group.bar2",
  ]
}
```

```
$ tfmigrate apply state_mv.hcl
```

The block has a type, a name, a `dir` pointing at the working directory the operations apply to, and a list of `actions` that are the state commands written as strings. The `mv` syntax is the one Terraform users already know.

The README's summary of the behaviour is that it works as you expect, but it is just a text file, so you can commit it to git. That is the entire pitch, and it is a good one. A state move that is expressed as a file can be reviewed, can be reordered, can be branched, and can be rolled back by reverting a commit, none of which is true of a command typed into a terminal.

The action vocabulary is broader than `mv`. The configuration reference lists `state mv`, `state xmv`, `state rm`, `state import` and `state replace-provider`, and the `xmv` form is the cross-state move, which is what makes the monorepo support work.

## The dry run is the feature that earns the trust

Among the four listed features, the dry run is the one that changes how a team works, and its description is precise.

Dry run migration: simulate state operations with a temporary local tfstate and check to see if `terraform plan` has no changes after the migration without updating the remote tfstate.

That is the check the Why section said the team wanted. The failure mode this tool is built around is a state move that is subtly wrong, which produces a plan with changes in it, which means the configuration and the state disagree, which is exactly the situation a CI pipeline will apply on your behalf while your colleague is working on something else.

So the tool gives you the answer before the merge. The operations are executed against a temporary local state, the plan is run, and if the plan comes back with no changes, the migration has done what it was supposed to. The remote state is not touched until you say so.

This is also the feature that explains a design constraint elsewhere in the documentation, which is that the tool uses the local backend for planning, which means some command line flags for the remote state backend cannot be used. The dry run is worth that limitation, because without it a migration is an act of faith.

## Migration history, and moving resources between states

The other two features are about the state of a repository over time and about its shape.

Migration history keeps track of which migrations have been applied and applies all unapplied migrations in sequence. The `history` block in the configuration is where that lives, and it means the tool behaves like a migration framework rather than a one-shot command. A repository accumulates migration files, the tool knows which ones have run, and a new checkout or a new machine can bring the state up to date by applying what is missing.

Monorepo style support is described as moving resources to other tfstates to split and merge easily for refactoring, and it has its own block type. The `multi_state` block carries `multi_state mv` and `multi_state xmv` actions, which is the case where a resource moves from one state file to another as part of a split or a merge of a monorepo into separate states.

That is the operation that a text file is uniquely good at. A split of a large Terraform setup into a hundred per service states is a sequence of cross-state moves, and expressing it as one reviewed file is the difference between a scripted procedure and a reproducible change.

## It shells out to terraform or tofu, on purpose

The requirements section states the design decision that governs everything else. The tfmigrate invokes `terraform` or `tofu` command under the hood. This is because the project wants to support multiple Terraform and OpenTofu versions in a stable way.

That is an unusual choice for a Go tool that could have embedded a state manipulation library, and the reason given is compatibility. Rather than reimplementing the semantics of every state command and tracking upstream changes itself, the tool drives whichever binary is installed, so a change in Terraform's behaviour is a change in the binary rather than a change in this tool.

The version requirements follow. For Terraform the minimum is v0.12 or higher, with v1.x recommended. For OpenTofu, a community fork of Terraform, you set the environment variable `TFMIGRATE_EXEC_PATH` to `tofu`, and the minimum is OpenTofu v1.6 or higher.

Terragrunt needs its own treatment, and the documentation is careful about the version boundary. If you are not leveraging Terragrunt's dynamic state generation, `TF_MIGRATE_EXEC_PATH` must be set based on your Terragrunt version, and the difference is a CLI redesign:

```shell
# For Terragrunt < v0.73.0:
TFMIGRATE_EXEC_PATH=terragrunt tfmigrate $OTHEROPTIONS
```

```shell
# For Terragrunt >= v0.73.0 (due to the CLI redesign):
TFMIGRATE_EXEC_PATH="terragrunt run --" tfmigrate $OTHEROPTIONS
```

And if you do use dynamic state generation, the `remote_state` block must include a `generate` block, because the tool plans against the local backend and therefore needs the backend configuration in a file rather than in command line flags:

```hcl
remote_state {
  backend = "s3"

  config = {
    bucket         = "highway-terraform-state"
    # Other config here
  }
  # This ensures that a file instead of command line flags are used.
  # allowing tfmigrate to work as expected.
  generate = {
    path      = "backend.tf"
    if_exists = "overwrite_terragrunt"
  }
}
```

## The sandbox lets you learn the tool without credentials

The Getting Started section opens with a warning and a gift. Terraform state operations are dangerous if you do not understand what you are actually doing, and the author's advice is that if he were you, he would not use a new tool in production from the start, and recommends playing in an example sandbox environment first, which is safe to run the state command without any credentials.

The sandbox mocks the AWS API with localstack and does not actually create any resources, so you can run both tfmigrate and terraform and understand how the tool works. The compose file shows the pieces: a `tfmigrate` service, a `localstack` service on localstack 2.0.2 with an S3 bucket named `tfstate-test`, a `fake-gcs-server` on the fake-gcs-server image, and a `dockerize` helper.

Two of the environment variables in that compose file are honest engineering notes. The plugin cache is shared and the `TF_PLUGIN_CACHE_MAY_BREAK_DEPENDENCY_LOCK_FILE` flag is set, with a comment about using the same filesystem to avoid a checksum mismatch or a file busy error from asynchronous IO. And `TF_CLI_ARGS_apply` is set to `--parallelism=1`, with the comment that although the root cause is not certain, localstack sometimes misses API requests when run in parallel.

The `Makefile` shows the rest of the test story. There is a `testacc` target that runs the acceptance tests with `TEST_ACC=1`, a `generate-plugin-cache` target, and a `legacy-tfstate` target that runs `hashicorp/terraform:0.12.31` in Docker to generate an old format state file for the replace-provider tests, which is a neat way of testing a modern tool against the state files it has to keep reading.

## Where this is the wrong tool

The first wrong case is a team that does not manage configuration in git. The tool's premise is that the configuration and the state operations belong in the same pull request. If your state changes are documented in a runbook or tracked in a ticket, there is nothing for the history and the dry run to add.

The second is a workflow that never moves resources between states. A simple `terraform state mv` typed once does not need a file, a history and a dry run, and the setup cost of configuring the storage block for S3 or GCS is only justified if you have many migrations to run.

The third is a team that is treating this as a substitute for understanding. A declarative migration file is easier to review than a shell script, and that is an argument for the file being readable, not for the operations being safe to write without thought. The README's own warning, that state operations are dangerous if you do not understand what you are actually doing, applies just as much to a well formatted HCL file.

The fourth is a constraint worth naming: the tool drives an external binary, so it inherits that binary's version, its authentication and its configuration. If your state backend is configured in a way the tool cannot express, the documentation says some command line flags cannot be used, and you are back to hand running commands.

On the state of the project, the releases are v0.4.3 on 2025-08-10, v0.4.4 on 2025-12-22 and v0.4.5 on 2026-05-04, and the last push to the repository was on 2026-09-26, with the repository not archived. The licence is MIT, the module requires Go 1.25, and the code is a conventional Go layout with `command/`, `config/`, `history/`, `storage/`, `tfexec/` and `tfmigrate/` packages, which is a fair map of what the tool does.

## Conclusion

Adopt tfmigrate if your team manages Terraform configuration in git and has been doing state surgery by hand, because the tool turns those operations into reviewable files and adds the check that matters, a plan showing no changes after the migration. Do not adopt it for a workflow that keeps state operations in a runbook or a ticket, since that is the practice it is designed to replace, and be aware that it is not a replacement for understanding what a state move does. Verify first which binary it should invoke, `terraform` or `tofu` and, if you use Terragrunt, which invocation form your version needs, and that your remote state configuration can be expressed in a file rather than command line flags, since the dry run relies on the local backend.

## FAQ

### What does tfmigrate do?

It applies Terraform and OpenTofu state operations written declaratively. You write a migration file in HCL containing state mv, rm, import or replace-provider actions, and apply it with `tfmigrate apply`, so the operations can be committed to git and reviewed like configuration.

### What does the tfmigrate dry run do?

It simulates the state operations with a temporary local tfstate and checks whether terraform plan has no changes after the migration, without updating the remote tfstate. That lets you verify a migration before the remote state is touched.

### Does tfmigrate work with OpenTofu and with Terragrunt?

With OpenTofu you set `TFMIGRATE_EXEC_PATH` to `tofu` and need v1.6 or higher. With Terragrunt, `TF_MIGRATE_EXEC_PATH` must be set to `terragrunt` for versions below v0.73.0 and to `terragrunt run --` from v0.73.0 onwards, because of the CLI redesign, and if you use dynamic state generation the `remote_state` block needs a `generate` block with `if_exists = "overwrite_terragrunt"`.

### How can I try tfmigrate without touching a real cloud account?

The project ships a sandbox that mocks the AWS API with localstack and does not create any resources. You build it with `docker compose build` and open a shell with `docker compose run --rm tfmigrate /bin/bash`, then work from the test fixtures with a Terraform configuration containing two `aws_security_group` resources.

### Can tfmigrate move resources between different state files?

Yes. Monorepo style support covers moving resources to other tfstates to split and merge easily for refactoring, using the `multi_state` block with `multi_state mv` and `multi_state xmv` actions.

## Sources

- [Issues](https://github.com/minamijoyo/tfmigrate/issues)
- [License: MIT](https://github.com/minamijoyo/tfmigrate/blob/master/LICENSE)
- [minamijoyo/tfmigrate on GitHub](https://github.com/minamijoyo/tfmigrate)
- [README](https://github.com/minamijoyo/tfmigrate/blob/master/README.md)
- [Releases](https://github.com/minamijoyo/tfmigrate/releases)

---

Hysen Labs editorial analysis, written from the project's own repository and release notes. Cite the canonical page: https://hysenlabs.com/projects/minamijoyo-tfmigrate
