Skip to content

Terraform Workspaces

A Terraform workspace lets one configuration and one backend produce several independent state files, switched by name from the CLI, with zero duplicated .tf code.

Every Terraform configuration starts with a single workspace named default. You create and switch between additional named workspaces with three commands:

Terminal window
# Create a new workspace and switch to it immediately
terraform workspace new dev
# List every workspace; the active one is marked with an asterisk
terraform workspace list
# default
# * dev
# staging
# prod
# Switch to an existing workspace
terraform workspace select staging

Each workspace gets its own state file, but every workspace still reads the exact same .tf files and points at the exact same backend configuration. Running terraform plan in the dev workspace only ever looks at the dev state; switch to staging and Terraform behaves as if it has never seen the staging resources before, because as far as that state file is concerned, it has not.

The active workspace name is available inside your configuration as the built-in value terraform.workspace. Interpolating it lets one set of .tf files behave differently depending on which workspace is currently selected:

locals {
instance_size = {
dev = "t3.micro"
staging = "t3.small"
prod = "m5.large"
}
}
resource "aws_instance" "app" {
ami = data.aws_ami.app.id
instance_type = local.instance_size[terraform.workspace]
tags = {
Name = "app-${terraform.workspace}"
}
}

This is genuinely DRY: there is exactly one aws_instance block, one place to fix a bug in it, and the instance size and name still come out different per workspace. No copy-pasted directories, no duplicated resource definitions.

Here is the limitation to hold onto: a workspace only isolates state. It changes nothing else. Every workspace built from the same configuration shares:

  • The exact same backend configuration — same S3 bucket, same region, same account.
  • The exact same provider configuration — same AWS credentials, unless you write conditional logic to vary them.
  • The exact same .tf code — there is no hard boundary preventing a dev workspace from containing a resource block that, if misconfigured, is just as capable of touching production-scale infrastructure as any other workspace.

That makes workspaces a good fit for lightweight, structurally identical variation — a short-lived feature-branch preview environment that just needs its own state and a smaller instance size is a great use case. It is a poor fit the moment an environment needs a genuinely different AWS account, a different region, or different credentials from the others, because a workspace cannot express any of that; it is just a name.

The sharper, everyday risk is human, not architectural: the active workspace is a piece of CLI-selected state that lives outside your .tf files entirely. Nothing stops an engineer from believing they are in dev, running terraform apply, and discovering only afterward that the terminal was still sitting in prod from an hour earlier. There is no separate credential, no separate account boundary, no confirmation prompt tied to the workspace name — just a string that is easy to forget.

flowchart LR
  cfg["One .tf config + one backend"] --> dev["workspace: dev (own state file)"]
  cfg --> staging["workspace: staging (own state file)"]
  cfg --> prod["workspace: prod (own state file)"]
One Terraform config and backend, three workspaces, three separate state files — everything else shared

Proof: real state paths, terraform.tfstate.d/, and a real value flip

Section titled “Proof: real state paths, terraform.tfstate.d/, and a real value flip”

All of the above is provable offline with local_file standing in for aws_instance (same terraform.workspace mechanism, no cloud needed to see it):

main.tf
locals {
instance_size = {
default = "t3.micro"
dev = "t3.micro"
staging = "t3.small"
prod = "m5.large"
}
}
resource "local_file" "app" {
filename = "${path.module}/app-${terraform.workspace}.txt"
content = "workspace=${terraform.workspace} size=${local.instance_size[terraform.workspace]}\n"
}

Real apply in the default workspace, then terraform workspace new staging and apply again with the exact same config:

$ terraform apply -auto-approve # default workspace
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform workspace new staging
Created and switched to workspace "staging"!
$ terraform apply -auto-approve # staging workspace, same .tf files
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

The two generated files prove terraform.workspace really did flip per workspace, from the identical resource block:

$ cat app-default.txt
workspace=default size=t3.micro
$ cat app-staging.txt
workspace=staging size=t3.small

And the real on-disk state layout — this is the concrete shape terraform.tfstate.d/WORKSPACE/ takes, proving the “own state file” claim above with an actual find, not an assertion:

$ find . -iname "*.tfstate*" -not -path "./.terraform/*"
./terraform.tfstate
./terraform.tfstate.d
./terraform.tfstate.d/staging/terraform.tfstate

The default workspace is the one special case: its state lives at the plain terraform.tfstate path, exactly where state would live if workspaces were never used at all — every other workspace gets a subdirectory under terraform.tfstate.d/. terraform workspace list confirms both exist and which is active:

$ terraform workspace list
default
* staging

Why directories beat workspaces for real environment isolation

Section titled “Why directories beat workspaces for real environment isolation”

The terraform.tfstate.d/ layout above is itself the argument: default and staging state files sit inside the same working directory, read by the exact same .tf files, through the exact same backend configuration. There is no directory boundary, no separate credentials, and nothing on disk that structurally prevents a command run in the wrong workspace from touching the wrong environment — the only thing separating dev from prod is which name happened to be active in the terminal at the moment apply ran. The next lesson’s directory-per-environment pattern trades away the DRY-ness proven above (one resource block, one place to fix a bug) for a structural guarantee workspaces cannot offer: a prod/ directory is a different cd, often a different set of credentials, and — critically — a mistake typed while sitting in dev/ has no path to reach prod/ at all, whereas a mistake typed while the terminal happens to be on the prod workspace has every path to reach it.

When reviewing generated workspace-based configs, check for:

  • A local.instance_size (or similar) lookup map missing a key for one of the workspaces actually in use — proven above, terraform.workspace evaluates to whatever string is currently active, and indexing a map with a missing key is a real Terraform error, not a silent fallback to some default.
  • Any script or CI pipeline that runs terraform apply without first confirming (terraform workspace show) which workspace is active — proven above, workspace selection is process-local, persistent CLI state with no connection to which branch or environment a CI job believes it is deploying.
  • terraform.workspace used to vary something structural — a provider’s region, an entirely different backend bucket — rather than a resource-level value like size or a tag. Proven above, a workspace shares one backend and one provider config across every workspace; code that tries to make those vary per workspace is fighting the mechanism.

🐛 Spot the bug

locals {
instance_size = {
dev = "t3.micro"
staging = "t3.small"
}
}
resource "aws_instance" "app" {
instance_type = local.instance_size[terraform.workspace]
}
Show the answer

The lookup map only has dev and staging keys. Proven above, terraform.workspace evaluates to whatever workspace is actually active — the moment someone creates and switches to a prod workspace (an entirely reasonable, expected action given this module’s whole subject), local.instance_size[terraform.workspace] fails with a real “key does not exist” error at plan time. The fix is either a complete map covering every real workspace, or a default fallback via lookup(local.instance_size, terraform.workspace, "t3.micro").

A workspace is a pointer, not a boundary — proven above, terraform.tfstate.d/NAME/ is the entire mechanism, a subdirectory Terraform picks based on one string, read against one unchanging set of .tf files and one unchanging backend. Every “isolation” a workspace provides is downstream of that one string being correct at the moment a command runs — proven above with the real state-file layout, there is no additional structural wall anywhere.

ChoiceBenefitCost
WorkspacesGenuinely DRY — one resource block, proven above to produce different values per workspace with zero duplicationZero isolation of backend/provider/.tf code, proven above; the only thing separating environments is which name is currently active
Directory-per-environment (next lesson)Structural boundary — no path from a dev/ mistake to prod/Reintroduces the copy-paste-per-directory risk from two lessons ago unless paired with a shared module
  • “Each workspace can have its own provider region or backend bucket.” Proven above: every workspace reads the exact same backend configuration and provider configuration. Only the state file differs.
  • “The default workspace is stored the same way as every other workspace, just named differently.” Proven above with a real find: default’s state sits at the plain terraform.tfstate path with no terraform.tfstate.d/ subdirectory at all — every other workspace does get one.
  • “Switching workspaces is safe because Terraform would warn me if I’m about to apply against the wrong one.” Proven above by absence: nothing in the commands run demonstrated any such warning — terraform workspace show/list are the only way to check, and nothing forces you to run them before apply.
What does a Terraform workspace actually isolate, and what does it not isolate
What is terraform.workspace typically used for inside a configuration
What is the specific real-world risk that comes from using workspaces for environment separation
When is using workspaces a good fit versus a poor fit
Where does the default workspace state file real on-disk path, as proven above, differ from every other workspace?
A local.instance_size map only defines dev and staging keys, but someone creates and switches to a prod workspace using this same config. What happens, proven above?