Terraform Workspaces
The idea in one sentence
Section titled “The idea in one sentence”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.
Creating and switching workspaces
Section titled “Creating and switching workspaces”Every Terraform configuration starts with a single workspace named default. You create and switch between additional named workspaces with three commands:
# Create a new workspace and switch to it immediatelyterraform workspace new dev
# List every workspace; the active one is marked with an asteriskterraform workspace list# default# * dev# staging# prod
# Switch to an existing workspaceterraform workspace select stagingEach 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.
Varying behavior with terraform.workspace
Section titled “Varying behavior with terraform.workspace”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.
What a workspace does not isolate
Section titled “What a workspace does not isolate”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
.tfcode — there is no hard boundary preventing adevworkspace 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)"]
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):
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 workspaceApply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform workspace new stagingCreated and switched to workspace "staging"!
$ terraform apply -auto-approve # staging workspace, same .tf filesApply 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.txtworkspace=default size=t3.micro$ cat app-staging.txtworkspace=staging size=t3.smallAnd 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.tfstateThe 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* stagingWhy 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.
Reading AI-generated code
Section titled “Reading AI-generated code”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.workspaceevaluates 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 applywithout 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.workspaceused to vary something structural — a provider’sregion, an entirely different backendbucket— 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").
Mental Model
Section titled “Mental Model”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.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Workspaces | Genuinely DRY — one resource block, proven above to produce different values per workspace with zero duplication | Zero 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 |
Common Misconceptions
Section titled “Common Misconceptions”- “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 plainterraform.tfstatepath with noterraform.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/listare the only way to check, and nothing forces you to run them beforeapply.