Bicep vs Terraform on Azure: An Honest Comparison After Building Both

I have built and maintained hands-on labs in both of these, dozens of them, which means I have written the same infrastructure twice more times than most people would consider reasonable. Storage accounts, virtual networks, container apps, key vaults, the lot. That is a slightly odd qualification, but it does mean I have hit the sharp edges on both sides rather than the sharp edges on one side and the marketing on the other.
Most comparisons you will find are written by someone who uses one of them daily and has opinions about the other from a conference talk. They tend to land on "Bicep if you are Azure-only, Terraform if you are multi-cloud", which is true, useless, and not actually the deciding factor for most teams.
The real differences are in state, in what happens on day two, and in what your team does when something drifts. Here is what I have found.
If you have only used one of the two, the Terraform side is worth seeing in motion before you read my take on it:
The Same Thing, Written Twice
Start with what they look like, because the syntax difference is real but much smaller than people expect.
Bicep:
param location string = resourceGroup().location
param environment string
var storageName = 'stapp${environment}${uniqueString(resourceGroup().id)}'
resource storage 'Microsoft.Storage/storageAccounts@2023-05-01' = {
name: storageName
location: location
sku: { name: environment == 'prod' ? 'Standard_ZRS' : 'Standard_LRS' }
kind: 'StorageV2'
properties: {
minimumTlsVersion: 'TLS1_2'
allowBlobPublicAccess: false
supportsHttpsTrafficOnly: true
}
}
output storageId string = storage.id
Terraform:
variable "environment" { type = string }
resource "azurerm_storage_account" "app" {
name = "stapp${var.environment}${random_string.suffix.result}"
resource_group_name = azurerm_resource_group.app.name
location = azurerm_resource_group.app.location
account_tier = "Standard"
account_replication_type = var.environment == "prod" ? "ZRS" : "LRS"
min_tls_version = "TLS1_2"
allow_nested_items_to_be_public = false
https_traffic_only_enabled = true
}
output "storage_id" { value = azurerm_storage_account.app.id }
Roughly the same length, roughly the same readability. If someone tells you one of these is dramatically more elegant than the other, they are describing a preference. This is not where the decision lives.
Where It Actually Lives: State
This is the difference that shapes everything else, and it is not a feature comparison, it is an architectural one.
Terraform keeps its own state file. It records what it believes it created, and plan compares that record against reality and against your code. The state file is a real artifact you have to store somewhere, lock during runs, protect because it contains secrets in plain text, and occasionally repair by hand.
Bicep has no state. Azure Resource Manager is the state. A deployment submits a desired configuration and ARM reconciles it against what exists. Nothing to store, nothing to lock, nothing to corrupt.
That sounds like Bicep wins outright, and for the first six months it does. Then you hit the consequence.
# Terraform knows this resource is "its" and will remove it
terraform destroy -target=azurerm_storage_account.app
# Bicep's default incremental mode will not remove anything.
# Delete the resource block and redeploy, and the storage account stays.
az deployment group create --resource-group rg-app --template-file main.bicep
Terraform's state gives it ownership. It knows what it created, so it can clean up. Bicep in incremental mode only ever adds and updates, which means deleting a resource from your template does nothing at all. Your code and your environment diverge silently, and there is no command that tells you.
Complete mode exists and does delete anything in the resource group that is not in the template, which is a genuinely dangerous instrument that I would not point at a shared resource group.
Gotcha: The commonest Bicep production incident I have seen is not a bad deployment, it is a resource that was removed from the template months earlier and is still running, still billing, and no longer described anywhere. Terraform makes that state impossible. Bicep makes it the default.
Drift Detection
Both have a preview command. They are not equivalent.
# Bicep
az deployment group what-if \
--resource-group rg-app \
--template-file main.bicep \
--parameters environment=prod
# Terraform
terraform plan -var="environment=prod"
terraform plan is authoritative because Terraform refreshes state against the real world first, so it sees changes made outside Terraform. If someone edited a setting in the portal, plan shows it and offers to revert it.
what-if is genuinely useful and it is less complete. It has known gaps with certain resource types and nested properties, and it will sometimes report a change to a property that is not actually changing. Teams learn which noise to ignore, which is exactly the habit you do not want, because the day it reports something real you have already trained yourself past it.
For detecting portal drift specifically, Terraform is meaningfully better.
Module Ecosystems
Both now have Azure Verified Modules, which narrows a gap that used to be enormous.
module storage 'br/public:avm/res/storage/storage-account:0.30.0' = {
name: 'storage'
params: { name: storageName, location: location }
}
module "storage" {
source = "Azure/avm-res-storage-storageaccount/azurerm"
version = "0.6.4"
name = local.storage_name
location = var.location
resource_group_name = azurerm_resource_group.app.name
}
AVM is a real equaliser, and if you are starting today it removes what used to be the strongest practical argument for Terraform on Azure.
Outside AVM the Terraform registry is still far larger, and more importantly it covers things that are not Azure at all. If your infrastructure includes Cloudflare DNS, a Datadog monitor and a GitHub repository alongside your Azure resources, Terraform manages all of it in one graph. Bicep manages the Azure part and you glue the rest on somehow.
There is one Bicep advantage worth naming: new Azure resource types and API versions are available in Bicep immediately, because Bicep is a transparent layer over the ARM schema. The AzureRM provider lags, sometimes by months. If you work with preview services, that gap is felt regularly, and the azapi provider is the workaround rather than a solution.
Day Two
This is where I have watched teams change their minds.
Secrets in state. Terraform state contains every attribute of every resource, including generated passwords and connection strings, in plain text. You can encrypt the backend, and the state is still a file full of secrets that CI has to read. Bicep has no equivalent exposure, though deployment outputs have the same problem if you output secrets, which you should not.
Concurrency. Terraform locks state, so two pipelines cannot apply at once. That is correct and it is also a queue, and on a busy platform repository the queue is felt. ARM handles concurrent deployments to the same resource group without a lock, though you can still get conflicts at the resource level.
Blast radius on rename. Rename a Terraform resource block and Terraform reads it as destroy-then-create unless you write a moved block. I have watched a production database get scheduled for replacement because someone tidied up resource naming. Bicep has no equivalent, because the resource identity is the Azure name, not a label in your code.
Onboarding. A .NET or PowerShell heavy team gets productive in Bicep faster, because it is one language for one cloud and the editor tooling is excellent. A team that has used Terraform anywhere else brings the mental model with them and the Azure provider is just another provider.
What I Actually Recommend
I have stopped giving a general answer, because the honest one depends on two questions.
Is Azure the whole picture, now and in two years? Not "are you multi-cloud today", but is there SaaS in your stack you would rather manage as code. DNS at a third party, identity provider config, monitoring, GitHub itself. If yes, Terraform, because managing half your infrastructure as code and clicking the other half is worse than either option.
Does your team already know Terraform? If so, use it. The cost of retraining and rewriting is real and Bicep's advantages are not large enough to pay for it.
If Azure genuinely is the whole picture and the team has no Terraform background:
| Situation | I would pick |
|---|---|
| Azure-only, .NET or PowerShell team, greenfield | Bicep |
| Azure-only but the team knows Terraform | Terraform |
| Any non-Azure infrastructure as code | Terraform |
| Heavy use of preview Azure services | Bicep |
| Strong requirement to detect and correct portal drift | Terraform |
| Platform team publishing modules to many app teams | Either, both have AVM and a registry |
Pro tip: Whichever you pick, pick one. The genuinely bad outcome is a Terraform platform layer and Bicep application deployments, with a hand-maintained contract between them. I have seen that arrangement twice and both times the seam was where the incidents happened.
The One That Surprised Me
Having built the same content in both, the thing I did not expect is how much the deletion semantics dominate everything else in practice.
Every other difference is a preference you adapt to. Bicep's inability to remove what you deleted from your template is a structural gap you have to build a process around: periodic reviews, tagging conventions, a cost report that flags orphans. Teams that adopt Bicep and skip that process accumulate untracked resources, without exception, in every environment I have audited.
That is not a reason to avoid Bicep. It is the thing to plan for on day one rather than discover in year two.
Conclusions
The syntax difference is not the story. State is. Terraform's state file buys you ownership, drift detection and clean removal, and costs you a file full of secrets, a lock, and destroy-on-rename. Bicep's statelessness buys you simplicity and day-one Azure coverage, and costs you the ability to delete anything by removing it from your code.
AVM has closed the module gap, so if you are Azure-only with an Azure-native team, Bicep is a better default than it was two years ago. The moment anything outside Azure needs managing as code, that calculus flips and it is not close.
What's Next
- Azure Verified Modules if you are going the Bicep route and want the module story sorted first
- Testing Bicep with what-if and PSRule, which is how you narrow the drift gap described above
- Bicep modules and the private registry for encoding your own conventions in either ecosystem




