Bicep vs Terraform in 2026: how to choose for Azure

Picture a tidy-up pull request on a Friday afternoon. Someone notices a storage account called stlegacyexports in the infrastructure code, decides nobody uses it any more, deletes the twelve lines that declare it, and gets an approval. The pipeline goes green.

What happens to the account depends entirely on which tool owns it:

Where the account is managed Expected behavior after removing its declaration
Standard Bicep deployment in incremental mode The account remains in Azure
Bicep deployment stack The account is detached or deleted according to the stack’s actionOnUnmanage setting
Terraform, with the account tracked in state The next normal plan proposes destroying the account

Same edit, three outcomes. If that account happened to hold the only copy of last year’s exports, the row you’re in matters a lot more than which language you find nicer to write. These are documented ARM behaviors and Terraform lifecycle behaviors; locks, permissions, service restrictions, or an explicit stack detach or Terraform removed block can change the result.

That difference in resource ownership is the real decision behind “Bicep or Terraform”, and most of this guide follows from it: how each tool previews changes, how much of the Azure API it exposes, and how it adopts resources that already exist. The code is illustrative, and I haven’t benchmarked anything for this article, so treat any speed claim, including the one I made last time, as something to test yourself.

What I got wrong last time, and what changed

I went back to my 2025 comparison because the tooling had moved on enough to deserve an update. Rereading it, I found it had also got several things wrong:

  • I overstated Bicep’s automatic cleanup.
  • I called manual cleanup a Terraform weakness, even though removing a state-tracked resource makes Terraform propose its destruction.
  • I claimed Bicep was “generally faster” without a benchmark.
  • I blurred the differences between Terraform and OpenTofu.
  • I listed Terraform’s license as MPL 2.0.

Those were mistakes at the time, not things that changed in 2026. I also left out two things that already existed: Terraform’s direct ARM API access through AzAPI, and OpenTofu’s state encryption, which shipped in 1.7.0 in April 2024.

What is new: Microsoft has published dedicated documentation for deployment stack what-if, including previews of detached and deleted resources.

The reference versions for this article are the latest stable releases as of October 25, 2026: Bicep v0.48.1, released earlier in October, and Terraform v1.17.0, released October 21.

State versus ARM deployments

Terraform stores bindings between configuration addresses and remote objects in state. If azurerm_storage_account.this remains in state but its resource block is removed, a normal plan proposes destroying the account. Before proposing changes, Terraform reads remote objects through providers and compares them with configuration and prior state. Teams need to protect the state and coordinate access to it. The AzureRM backend supports Azure Blob Storage, locking, and consistency checks.

terraform plan -out=tfplan

The saved plan can later be passed to terraform apply tfplan after review. Treat it as sensitive: plans and state can contain secrets, and marking a value sensitive is not encryption. Restrict access to the backend and plan artifacts. (Sensitive-data guidance)

Bicep deploys through Azure Resource Manager (ARM). It doesn’t keep a Terraform-style state file mapping resource addresses to remote objects. A standard incremental deployment leaves omitted resources in Azure, so removing a declaration alone leaves the resource behind.

For a managed collection with explicit removal behavior, use deployment stacks. On an update, resources removed from the template can be detached or deleted according to the stack’s configured behavior. A stack can also apply deny settings to help prevent unwanted changes. Microsoft recommends stacks rather than complete-mode deployments when you need template-driven deletion, and says complete mode will be gradually deprecated. (Deployment modes)

Stacks are not a general-purpose Terraform state replacement. They track an Azure resource collection rather than bindings across arbitrary providers. They also don’t manage implicitly created resources. Deny settings have scope limitations, including cases involving parent resource group deletion.

On paper, stacks close a real gap in Bicep: they’re designed to make that Friday-afternoon PR behave predictably instead of silently leaving the account behind. I’ll be honest that I know them better from the docs than from running them in anger, so weigh my view accordingly. For now, they haven’t changed my own preference: I still prefer Terraform. The preview section below shows one place where the two workflows still differ.

Preview the deployment model you actually use

For a standard Bicep deployment, the preview command is:

az deployment group what-if \
  --resource-group rg-demo \
  --template-file main.bicep \
  --parameters @dev.parameters.json

dev.parameters.json is a parameters file you supply.

A deployment stack has its own command. This one previews a stack update with detach behavior:

az stack-whatif group create \
  --name preview-demo \
  --resource-group rg-demo \
  --stack-id "<deployment-stack-resource-id>" \
  --template-file main.bicep \
  --parameters @dev.parameters.json \
  --action-on-unmanage detachAll \
  --deny-settings-mode none \
  --retention-interval PT3H

Use settings matching the intended stack update. A detach preview does not tell you the effect of a later delete-mode update.

Check --retention-interval against your CLI version. As of this writing, the stack what-if guide says the value must be between PT1H and PT3H, while the az stack-whatif CLI reference says between one and 30 days and uses P5D in its examples. Run az stack-whatif group create --help and use what your installed version accepts.

Stack what-if creates a result resource that can be retrieved during its retention interval. The result is for inspection; a later stack deployment sends a separate request to ARM. Terraform, by comparison, can apply a saved plan directly. Stack what-if also filters common noise, but only for stacks created or updated on or after August 13, 2026; older stacks need one deployment to establish a baseline. (Stack what-if reference)

There is a documentation mismatch as of this article’s date: the older known-issues page still says stack what-if is unavailable, while the newer dedicated page documents it. Confirm your client exposes the documented command before depending on it in CI.

ARM what-if can also leave resources unevaluated when expressions cannot be resolved, or skip parts of a deployment it cannot analyze. Review diagnostics and coverage, not just the visible diff. Terraform plans have their own unknown values and provider limitations; neither preview guarantees deployment success. (ARM what-if limitations)

Azure API coverage: Bicep, AzureRM, and AzAPI

Here are three separate configurations for the same storage account. They assume variables, authentication, and the resource group already exist. Bicep and AzAPI use the same API version.

Bicep: the ARM resource shape

param location string = resourceGroup().location
param storageAccountName string

resource storage 'Microsoft.Storage/storageAccounts@2025-06-01' = {
  name: storageAccountName
  location: location
  kind: 'StorageV2'
  sku: {
    name: 'Standard_LRS'
  }
  properties: {
    accessTier: 'Hot'
    allowBlobPublicAccess: false
    minimumTlsVersion: 'TLS1_2'
  }
}

Bicep describes ARM resources using their resource type and API version. Microsoft says Bicep can use new ARM resource types and API versions without waiting for a separate tooling update. This applies to ARM control-plane resources; it does not mean every service data-plane operation is available through ARM.

AzureRM: provider-specific arguments

resource "azurerm_storage_account" "this" {
  name                            = var.storage_account_name
  resource_group_name             = var.resource_group_name
  location                        = var.location
  account_kind                    = "StorageV2"
  account_tier                    = "Standard"
  account_replication_type        = "LRS"
  access_tier                     = "Hot"
  min_tls_version                 = "TLS1_2"
  allow_nested_items_to_be_public = false
}

AzureRM maps these settings to provider arguments. You don’t select a storage-account API version in this resource block; the provider implementation handles the API calls. In a real project, declare and constrain the provider version and configure provider "azurerm" with its required features {} block.

AzAPI: ARM resource shape, Terraform workflow

With AzAPI 2.x’s HCL object body syntax, the same settings look much closer to Bicep. Here, var.resource_group_id is the full ID of an existing resource group:

resource "azapi_resource" "storage" {
  type      = "Microsoft.Storage/storageAccounts@2025-06-01"
  name      = var.storage_account_name
  parent_id = var.resource_group_id
  location  = var.location

  body = {
    kind = "StorageV2"
    sku = {
      name = "Standard_LRS"
    }
    properties = {
      accessTier            = "Hot"
      allowBlobPublicAccess = false
      minimumTlsVersion     = "TLS1_2"
    }
  }
}

The storage-account API reference documents the ARM shape for both Bicep and AzAPI. The settings match, but don’t assume the defaults for everything you left out do too.

For a team already using AzureRM, AzAPI can provide access to an ARM feature that its AzureRM resource doesn’t yet expose. AzAPI can also be used on its own. Don’t let separate configurations independently own the same resource; design any provider handoff or property-level split explicitly.

AzureRM offers provider-specific resource abstractions. AzAPI exposes the ARM resource type and API version while keeping Terraform’s state and plan workflow. The choice is which layer your team wants to manage.

Sources: Bicep overview, Microsoft’s Terraform on Azure overview, AzAPI provider overview.

Module reuse

Both tools support reusable modules. A local module call looks different, but each lets you keep a deployment’s entry point readable and move repeated infrastructure patterns into reusable units.

module network './modules/network.bicep' = {
  name: 'network'
  params: {
    location: location
  }
}
module "network" {
  source   = "./modules/network"
  location = var.location
}

Bicep modules can be published to a private registry or shared as template specs. Terraform modules can come from registries, Git repositories, object storage, or local paths; registry modules can be version-constrained. (Bicep modules, Terraform modules)

Azure Verified Modules

I like Azure Verified Modules (AVM) a lot, in either language. AVM is Microsoft’s initiative to set one standard for what a good infrastructure module looks like, and to publish modules that meet it for both Bicep and Terraform. The project is now owned by Azure engineering, and Microsoft supports the modules. On the Bicep side, AVM is all that’s left in the public registry: non-AVM modules have been retired from it. (AVM project, Bicep public registry)

Here is the same storage account from earlier, deployed through the AVM resource module in each language. These are the latest published versions as of this writing:

module storage 'br/public:avm/res/storage/storage-account:0.33.1' = {
  params: {
    name: storageAccountName
    skuName: 'Standard_LRS'
    kind: 'StorageV2'
    accessTier: 'Hot'
    allowBlobPublicAccess: false
    minimumTlsVersion: 'TLS1_2'
  }
}
module "storage" {
  source  = "Azure/avm-res-storage-storageaccount/azurerm"
  version = "0.10.0"

  name                            = var.storage_account_name
  parent_id                       = var.resource_group_id
  location                        = var.location
  account_kind                    = "StorageV2"
  account_sku_name                = "Standard_LRS"
  access_tier                     = "Hot"
  min_tls_version                 = "TLS1_2"
  allow_nested_items_to_be_public = false
}

The calls look similar, yet the modules differ in defaults and implementation. Before you adopt one, check these points:

  • The defaults are opinionated, and they differ between languages. Leave the SKU out and you get Standard_GRS from the Bicep module but Standard_ZRS from the Terraform one. The Terraform module also turns off shared-key access and public network access by default and applies a deny-by-default network rule. The Bicep module enables infrastructure encryption and blob soft delete by default. These are good security choices, but they won’t match a hand-written resource or an existing account. Read the module’s inputs before pointing it at a brownfield resource, for the same reason the incremental-mode warning below matters.
  • The Terraform module is built on AzAPI. Despite the azurerm in its name, version 0.10.0 manages the account through AzAPI and requires Terraform 1.10 or later with AzAPI ~> 2.11. That’s fine, but your provider constraints have to allow it.
  • Both are still 0.x. Under semantic versioning, anything can change before 1.0. The Terraform module’s 0.7.0 release, for example, moved child resources into submodules and needed moved blocks to avoid destroy-and-recreate plans. Pin exact versions and read the changelog before you upgrade.
  • Telemetry is on by default. Both modules send usage telemetry to Microsoft unless you set enableTelemetry: false (Bicep) or enable_telemetry = false (Terraform).

Because AVM covers both languages, module availability is a weaker reason to pick one tool over the other. The better question is whether the specific module you need is mature, versioned the way you want, and matches your conventions. (Bicep storage-account module, Terraform storage-account module)

Brownfield infrastructure and imports

For an existing resource, the adoption paths differ: Terraform imports it into state; Bicep can describe it in a normal deployment at the matching scope.

Terraform has explicit import workflows. An import block maps an existing Azure resource ID to a Terraform resource address, while a matching resource block describes how Terraform should manage it going forward. Terraform can also generate configuration for some import workflows. (Terraform import)

import {
  to = azurerm_storage_account.example
  id = "/subscriptions/<subscription-id>/resourceGroups/<resource-group>/providers/Microsoft.Storage/storageAccounts/<name>"
}

A normal Bicep resource declaration can target the existing account through ARM, provided its name and deployment scope identify that account. The existing keyword serves a different purpose: it lets another deployment reference the account without deploying it again.

resource storage 'Microsoft.Storage/storageAccounts@2025-06-01' existing = {
  name: 'youractualstorageaccount'
}

output blobEndpoint string = storage.properties.primaryEndpoints.blob

This existing declaration only reads the account. To apply configuration changes, use a normal resource declaration with an accurate desired configuration. (Existing-resource reference)

Incremental mode can reset omitted properties to defaults when a resource is redeployed. Capture all required non-default settings, including relevant child-resource relationships, before applying a template to an existing resource. (Incremental deployment guidance)

For either tool, start with a small, non-critical resource group. Confirm the first preview is understandable and does not propose surprising changes. Don’t use a production apply as your import test.

Licensing and OpenTofu

The original comparison listed current Terraform as MPL 2.0. Terraform v1.6.0 and later use the Business Source License 1.1 with an Additional Use Grant. It permits production use but excludes offering the software to third parties on a hosted or embedded basis as a competing product. The license defines a competitive offering as a paid product, including paid support, whose capabilities significantly overlap with IBM’s paid Terraform versions. Internal hosting is permitted. Each version changes to MPL 2.0 four years after publication. Read the current license for the terms that apply to your use case.

IBM completed its acquisition of HashiCorp in February 2025. When assessing Terraform, consider the license, product requirements, and operating model your team expects. (IBM announcement)

OpenTofu is a separate project under the Linux Foundation and uses the MPL 2.0. It began as a Terraform fork and has since added features independently, including state and plan encryption. This encrypts data within the tool, alongside any encryption supplied by the backend, and brings key-management and recovery responsibilities. If license terms or project governance matter, evaluate OpenTofu alongside Terraform and test your actual providers, modules, state migration, and CI workflows.

So, which one should you choose?

Your situation Start with Why
New infrastructure, Azure-only, ARM is your operating model Bicep Direct Azure resource types and API versions; no separate Terraform state backend
Azure plus AWS, Google Cloud, SaaS, or other providers Terraform One configuration and workflow can cover multiple provider ecosystems
Existing Terraform estate with working modules, state, and automation Terraform Keep the current tool unless a specific problem justifies migration
New Azure project, but Terraform is your team’s standard Terraform + AzureRM/AzAPI You can stay with Terraform while using ARM APIs where needed
Terraform license or governance is a blocker Evaluate OpenTofu It is a separate MPL-licensed project, with its own releases and compatibility details
Existing Azure deployment managed through ARM Test Bicep against the real estate A clean sample won’t reveal adoption or lifecycle edge cases

Whichever row fits, treat broad claims such as “Bicep is faster” as hypotheses. A short pilot against your own pipeline will tell you more about deployment time, drift detection, failure recovery, and operator effort than any comparison article.

Run a fair pilot

Use separate disposable resource groups and uniquely named, empty accounts for each candidate. Do not put production data in this test, and do not point both tools at the same account. Prepare complete configurations with pinned tool and provider versions first.

Run the same sequence for each deployment model, and write down what actually happens: outputs, unevaluated resources, failures, and manual steps.

Step Action What to record
1. Establish a baseline Deploy an empty storage account with an explicit access tier Whether the next preview is a no-op and what differences remain
2. Change it outside code Change the account access tier from Hot to Cool in Azure Whether the next preview identifies the drift on the configured property
3. Reconcile Review and apply the intended tier from code Whether the following preview returns to the baseline
4. Remove the declaration Keep the surrounding configuration, but remove only the account resource Terraform’s destroy proposal, standard incremental ARM’s retained resource, or the stack’s detach/delete prediction
5. Adopt an existing account Use a different disposable account created outside that configuration Terraform’s import steps or Bicep’s normal declaration, plus any unintended property changes

For step four, preview before applying. A stack’s detach and delete policies should be evaluated separately. If you approve any destructive operation, verify the exact account and confirm it is empty.

Clean up deliberately afterward: an account retained by an incremental deployment or detached from a stack still exists.

Then widen the pilot to a network, identity, application resource, and any relevant non-Azure provider. Check credentials, approvals, shared-module upgrades, recovery, and total operating effort. Don’t treat a no-change preview on one storage account as proof that a whole estate is ready.

My recommendation: keep a working infrastructure standard unless there is a specific reason to change it. I still prefer Terraform, though your constraints may point elsewhere. For a new Azure-only team, Bicep is a reasonable starting point, and deployment stacks are worth piloting when template-driven cleanup matters. If Terraform already serves your team well, keep it even for an Azure-only estate. Whichever you pick, know which row of that first table you’re in before someone opens the Friday-afternoon PR.

Written by

Hidde de Smet

I'm a Microsoft MVP (2026–2027), recognized for Azure Infrastructure as Code and Developer Tools, a certified Azure Solution Architect, and a Microsoft Certified Trainer. I help teams design and build cloud solutions with Azure, Infrastructure as Code, and DevOps. On this blog, I share practical guides and lessons learned on cloud architecture, AI, and developer tools—including GitHub Copilot, Terraform, and Bicep.

Start the conversation