Terraform Workspaces
The idea in one sentence
Section titled “The idea in one sentence”A Terraform workspace lets one configuration keep several separate state files, each under its own name, which is a cheap way to model dev, staging, and prod, but a workspace changes nothing about the backend, the provider, or the Azure subscription that configuration actually talks to.
One configuration, many state files
Section titled “One configuration, many state files”terraform workspace is a built-in command family for managing named state inside a single working directory. You create one workspace per environment, and Terraform keeps a completely separate state file for each:
terraform workspace new devterraform workspace new stagingterraform workspace new prod
terraform workspace list# dev# staging# * prod
terraform workspace select devThe active workspace is just a pointer: whichever one is selected determines which state file the next plan or apply reads and writes. With the default local backend, Terraform stores this under terraform.tfstate.d, one subdirectory per workspace:
tree terraform.tfstate.dterraform.tfstate.d├── dev│ └── terraform.tfstate├── staging│ └── terraform.tfstate└── prod └── terraform.tfstateWith a remote backend such as azurerm, the same idea applies — Terraform namespaces the state blob per workspace instead of per local directory — but the effect is identical: dev cannot see or overwrite prod’s resources, because they live in entirely separate state.
Varying behavior with terraform.workspace
Section titled “Varying behavior with terraform.workspace”Terraform exposes the name of the currently selected workspace as a built-in value, terraform.workspace, which you can interpolate anywhere a string is expected. The most common use is picking a size or a name per environment out of a lookup map:
locals { vm_size = { dev = "Standard_B1s" staging = "Standard_B2s" prod = "Standard_D2s_v5" }}
resource "azurerm_linux_virtual_machine" "app" { name = "app-${terraform.workspace}" resource_group_name = azurerm_resource_group.app.name location = azurerm_resource_group.app.location size = local.vm_size[terraform.workspace]
admin_username = "azureuser" # ...}Select dev and the VM is named app-dev and sized Standard_B1s. Select prod and the same .tf file produces app-prod sized Standard_D2s_v5, with no code duplicated anywhere. This is the appeal of workspaces: one set of .tf files, one place to fix a bug, and per-environment variation driven entirely by which workspace happens to be active.
What a workspace does not isolate
Section titled “What a workspace does not isolate”Here is the part that is easy to miss. terraform workspace new only ever creates a new state file. It does not create a new backend configuration, a new provider configuration, or a new Azure subscription. All of that lives in the same .tf code, shared verbatim across every workspace:
terraform { backend "azurerm" { resource_group_name = "tfstate-rg" storage_account_name = "tfstateacct001" container_name = "tfstate" key = "app.terraform.tfstate" }}
provider "azurerm" { features {} subscription_id = "00000000-0000-0000-0000-000000000000"}Every workspace — dev, staging, and prod alike — reads and writes through that one backend block and authenticates through that one provider block, against that one subscription_id. There is no workspace-scoped credential, no workspace-scoped subscription, and no workspace-scoped copy of the .tf code. The only thing that differs between workspaces is which state file gets touched and whatever you chose to key off terraform.workspace.
That makes workspaces a poor fit for environments that need genuinely separate Azure subscriptions, regions, or credentials, and it creates a specific, real risk: nothing in the tooling stops someone from being on the prod workspace while believing they are still on dev, and applying a change sized and scoped for a toy environment directly against production, because the backend, the provider, and the subscription behind the scenes never changed at all.
flowchart LR config["One Terraform config, one backend block"] -->|terraform workspace new dev| devWs["dev workspace"] config -->|terraform workspace new staging| stagingWs["staging workspace"] config -->|terraform workspace new prod| prodWs["prod workspace"] devWs --> devState["dev state file"] stagingWs --> stagingState["staging state file"] prodWs --> prodState["prod state file"] devWs -.-> shared["Same subscription_id, same backend, same .tf code"] stagingWs -.-> shared prodWs -.-> 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”Provable offline with local_file standing in for azurerm_linux_virtual_machine (same terraform.workspace mechanism):
locals { vm_size = { default = "Standard_B1s" dev = "Standard_B1s" staging = "Standard_B2s" prod = "Standard_D2s_v3" }}
resource "local_file" "app" { filename = "${path.module}/app-${terraform.workspace}.txt" content = "workspace=${terraform.workspace} size=${local.vm_size[terraform.workspace]}\n"}Real apply in default, then terraform workspace new staging and apply again with the same config:
$ terraform apply -auto-approve # default workspaceApply complete! Resources: 1 added, 0 changed, 0 destroyed.
$ terraform workspace new 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 flipped per workspace, from the identical resource block:
$ cat app-default.txtworkspace=default size=Standard_B1s$ cat app-staging.txtworkspace=staging size=Standard_B2sReal on-disk state layout — the concrete shape terraform.tfstate.d/WORKSPACE/ takes:
$ find . -iname "*.tfstate*" -not -path "./.terraform/*"./terraform.tfstate./terraform.tfstate.d./terraform.tfstate.d/staging/terraform.tfstatedefault is the one special case — its state lives at the plain terraform.tfstate path, exactly where state would live if workspaces were never used; every other workspace gets a terraform.tfstate.d/ subdirectory. terraform workspace list confirms both exist:
$ terraform workspace list default* stagingReading AI-generated code
Section titled “Reading AI-generated code”- A
local.vm_sizelookup map missing a key for a workspace actually in use —terraform.workspaceevaluates to whatever string is active, and indexing a missing key is a real error, not a silent fallback. - Any CI script running
terraform applywithout first checkingterraform workspace show— workspace selection is process-local, persistent state with no connection to which branch or environment CI believes it is deploying.
🐛 Spot the bug
locals {vm_size = { dev = "Standard_B1s" staging = "Standard_B2s"}}
resource "azurerm_linux_virtual_machine" "app" {size = local.vm_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, local.vm_size[terraform.workspace] fails with a real “key does not exist” error at plan time.
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/provider/subscription_id.
Trade-offs
Section titled “Trade-offs”| Choice | Benefit | Cost |
|---|---|---|
| Workspaces | Genuinely DRY — one resource block, proven above to produce different values per workspace | Zero isolation of backend/provider/subscription_id, proven above; the only thing separating environments is which name is active |
Common Misconceptions
Section titled “Common Misconceptions”- “Each workspace can have its own subscription_id or backend bucket.” Proven above: every workspace reads the exact same backend and provider configuration.
- “The default workspace is stored the same way as every other workspace.” Proven above with a real
find:default’s state has noterraform.tfstate.d/subdirectory at all.