Azure Resource Manager (ARM) JSON Templates: Reference and Bicep Migration

Last updated:

An ARM JSON template is a declarative JSON document that tells Azure Resource Manager which resources to create, update, or delete in a resource group, subscription, management group, or tenant scope. The format predates Bicep and remains the wire protocol of the ARM deployment API — Bicep compiles down to ARM JSON before submission, so anything Bicep can deploy, ARM JSON can deploy. A template has six top-level sections ($schema, contentVersion, parameters, variables, resources, outputs) and a fixed set of expression functions for references, string manipulation, and conditional logic. As of 2026 ARM JSON is fully supported but Bicep is Microsoft's recommended authoring language; this guide covers the JSON format end-to-end and shows the migration path with az bicep decompile.

Authoring or debugging an ARM JSON template? Paste it into Jsonic's JSON Validator — it pinpoints trailing commas, missing quotes, and bracket mismatches with exact line numbers before you spend Azure CLI cycles on validation.

Validate ARM Template JSON

ARM template structure: $schema, contentVersion, parameters, variables, resources, outputs

Every ARM JSON template is a single object with six top-level fields. $schema points to the deployment schema that ARM uses to validate the document — the canonical value is https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json# for resource-group deployments (different scopes have different schemas, but the 2019-04-01 deploymentTemplate covers the vast majority of templates). contentVersion is a four-part version string you control; ARM does not enforce semantics, but it shows up in the activity log and helps trace which revision of a template produced a deployment.

parameters are inputs the caller supplies at deployment time — names, locations, sizing toggles. Each parameter has a type (string, int, bool, object, array, secureString, secureObject) and optional defaultValue, allowedValues, minLength/maxLength, minValue/maxValue, and a metadata.description that surfaces in the Azure portal's template UI. Use secureString for secrets so the value never appears in deployment history.

variables are computed values reused across resources — they are evaluated once at deployment start. They are the right home for repeated expressions, derived names (concatenations of base names with environment suffixes), and lookup tables encoded as objects.

resources is the array that does the actual work. Each entry describes one Azure resource to create or update. outputs exposes values back to the caller — connection strings, resource IDs, runtime URIs that downstream stages need.

{
  "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
  "contentVersion": "1.0.0.0",
  "parameters": {
    "storageAccountName": {
      "type": "string",
      "minLength": 3,
      "maxLength": 24,
      "metadata": { "description": "Globally unique storage account name (3-24 lowercase)" }
    },
    "location": {
      "type": "string",
      "defaultValue": "[resourceGroup().location]"
    }
  },
  "variables": {},
  "resources": [
    {
      "type": "Microsoft.Storage/storageAccounts",
      "apiVersion": "2023-05-01",
      "name": "[parameters('storageAccountName')]",
      "location": "[parameters('location')]",
      "sku": { "name": "Standard_LRS" },
      "kind": "StorageV2",
      "properties": {
        "minimumTlsVersion": "TLS1_2",
        "allowBlobPublicAccess": false
      }
    }
  ],
  "outputs": {
    "storageAccountId": {
      "type": "string",
      "value": "[resourceId('Microsoft.Storage/storageAccounts', parameters('storageAccountName'))]"
    }
  }
}

ARM template functions: resourceId, reference, parameters, variables, concat

ARM JSON expressions are strings wrapped in square brackets: "[parameters('name')]". Inside the brackets you compose functions to produce a value. The most common functions, in roughly the order you'll use them:

  • parameters('name') — read a deploy-time parameter value. Pair with defaultValue in the parameters block when you want a usable default.
  • variables('name') — read a computed value from the variables block. ARM evaluates variables once at the start of deployment.
  • resourceId([subId], [resourceGroup], type, name [, child]) — build a fully-qualified resource ID string. The default two-argument form resourceId('Microsoft.Storage/storageAccounts', 'mystg') resolves against the current subscription and resource group.
  • reference(resourceId, apiVersion) — read the full property bag of a resource at runtime. Required when you need a computed value (storage endpoints, public IPs, managed identity principalIds) that only exists after creation.
  • concat(s1, s2, …) — concatenate strings or arrays. The workhorse for building resource names from parts: concat(parameters('baseName'), '-', parameters('env')).
  • resourceGroup(), subscription(), tenant() — return objects describing the current deployment scope. resourceGroup().locationis the canonical way to default a parameter to the resource group's region.
  • uniqueString(seed, …) — produce a deterministic 13-character hash from one or more seed values. Use it to generate globally-unique storage account names without collisions across deployments.

Functions nest freely: "[concat('stg', uniqueString(resourceGroup().id))]" generates a storage account name that stays stable across redeploys of the same resource group but does not collide with other resource groups.

API versions and how to find them

Every resource declaration must pin an apiVersion — a date string like 2023-05-01identifying which version of the resource provider's schema this template was authored against. Pinning is what makes ARM deployments deterministic: a template that worked yesterday will work tomorrow regardless of provider changes, because ARM uses exactly the schema you authored against.

Resource providers ship breaking changes between API versions — new required fields, renamed properties, removed defaults, behavior changes. Without a pinned apiVersion, your template would silently break the day a provider released a new version that dropped a property you depended on. There is no "latest" sentinel in ARM JSON; you must name a specific date.

Three ways to find current apiVersion values:

  • Azure portal— open a resource of the type you want to author, go to Properties → API version. Shows the version the portal's current code path uses (usually the latest stable).
  • Azure CLI az provider show --namespace Microsoft.Storage --query "resourceTypes[?resourceType=='storageAccounts'].apiVersions" -o table lists every published version for a given resource type, newest first.
  • Azure REST API reference at learn.microsoft.com/rest/api/ — authoritative documentation including preview versions and per-property changelogs.

Versioning advice:prefer the latest non-preview (stable) version when authoring new templates. Preview versions can change without notice, and a feature you depend on might be moved or renamed before GA. Bump apiVersions deliberately, not automatically — a new version may introduce required properties your existing template doesn't set. The pattern of pinning to apiVersions is similar to how a JSON Schema $schema field identifies which validator dialect to use against a document.

Conditional resources with the condition property

Any resource entry can carry a condition field — a boolean expression evaluated at deployment time. If the condition resolves to true, ARM deploys the resource normally; if false, ARM skips it entirely. The resource does not appear in the activity log as "skipped" — it's as if the entry weren't in the template at all.

{
  "type": "Microsoft.Network/publicIPAddresses",
  "apiVersion": "2023-09-01",
  "name": "[parameters('publicIpName')]",
  "location": "[parameters('location')]",
  "condition": "[equals(parameters('deployPublicIp'), 'yes')]",
  "properties": { "publicIPAllocationMethod": "Static" }
}

Common patterns the condition property enables:

  • Optional features— a public IP, a backup vault, a diagnostic setting that some deployments need and others don't.
  • Environment splits — deploy a smaller SKU in dev (or skip the resource entirely) versus production. Drive the condition off a parameters('environment') value.
  • One-shot resources — deploy a resource only on first deployment by checking a flag the pipeline flips after the bootstrap.

When a resource is conditional, references to it from other resources must handle the skipped case. The if()function pairs with conditions to return one value when the resource is deployed and another when it isn't — for example, a network interface that references a public IP only when the public IP itself is being deployed. Conditional logic in ARM JSON is composable but verbose; Bicep's if expressions on resources and properties read more cleanly, which is one of the most common reasons teams migrate.

copy property for resource loops

The copy property turns a single resource declaration into multiple deployed instances. Add a copy object with a name (used for referencing the loop) and a count (how many instances to create); inside the resource, use copyIndex() to read the current iteration index.

{
  "type": "Microsoft.Storage/storageAccounts",
  "apiVersion": "2023-05-01",
  "name": "[concat('stg', copyIndex(), uniqueString(resourceGroup().id))]",
  "location": "[parameters('location')]",
  "copy": {
    "name": "storageLoop",
    "count": "[parameters('accountCount')]",
    "mode": "parallel"
  },
  "sku": { "name": "Standard_LRS" },
  "kind": "StorageV2",
  "properties": { "minimumTlsVersion": "TLS1_2" }
}

Copy modes: parallel (the default) deploys all iterations concurrently — fast but order-independent. serial deploys one at a time in index order — slower but useful when iterations have hidden dependencies. With serial, pair it with batchSize to control how many iterations run together in each wave.

copy also works inside variables (to compute arrays) and inside properties (to build sub-arrays like network interface IP configurations). The property-level form is useful when you have one resource with N child entries — for example, a virtual network with a variable number of subnets.

Referencing a loop iteration: use copyIndex('loopName') to read the index of an outer loop from inside a nested loop. Pass an offset as the second argument to start the index at a non-zero value: copyIndex('storageLoop', 1) yields 1, 2, 3 instead of 0, 1, 2.

linkedTemplates and templateLink for modular deployments

The Microsoft.Resources/deployments resource type creates a child deployment from another template. Two forms exist: templateLink points to a JSON file at a URL (Azure Blob Storage, GitHub raw, any HTTPS endpoint); an inline template object nests the child template directly inside the parent. Linked templates are the standard pattern for splitting large templates across multiple files — they keep each file under the 1 MB inline limit and let different teams own different modules.

{
  "type": "Microsoft.Resources/deployments",
  "apiVersion": "2022-09-01",
  "name": "networkModule",
  "properties": {
    "mode": "Incremental",
    "templateLink": {
      "uri": "https://templates.example.com/network/main.json",
      "contentVersion": "1.0.0.0"
    },
    "parameters": {
      "vnetName": { "value": "[parameters('vnetName')]" },
      "addressPrefix": { "value": "10.0.0.0/16" }
    }
  }
}

Reading child outputs: use reference('networkModule').outputs.vnetId.value in the parent template to consume values the child surfaced via its outputs block. Add dependsOn: ['networkModule'] on any sibling resource that must wait for the child deployment to finish.

Deployment scopes for child deployments: by default a nested deployment runs in the same resource group as the parent. Set subscriptionId and resourceGroup on the deployments resource to target a different scope — useful for cross-resource-group orchestration (deploy networking in a shared RG, compute in a per-app RG).

Inline vs templateLink: inline templates are easier to ship (one file, one commit) and easier to test; templateLink is required when the full document exceeds 1 MB or when modules are versioned and consumed across many parent templates. For organizations standardizing IaC, AWS CloudFormation comparison shows how the equivalent pattern looks in AWS-land.

ARM JSON vs Bicep: which to use in 2026

Both formats deploy through the same Azure Resource Manager engine, so the runtime behavior is identical. The choice is about authoring ergonomics, tooling, and the existing template inventory you have to maintain.

ConcernARM JSONBicep
Wire format submitted to ARMARM JSONARM JSON (Bicep transpiles)
Lines of code (typical template)20070-90 (same template)
Type hints in VS CodeSchema-onlyFull IntelliSense per resource type and apiVersion
Resource referencesresourceId(), reference()Symbolic name (storage.id, storage.properties.…)
ModulesNested deployments (Microsoft.Resources/deployments)First-class module keyword with typed inputs/outputs
Conditionalscondition + if() functionif (expr) on resources and properties
Loopscopy property + copyIndex()for x in xs: on resources, properties, modules
Recommended for new authoringNoYes (since 2021)
Supported indefinitelyYesYes

Use ARM JSON whenyou have an existing template library you intend to maintain in place, when a third-party tool emits or consumes ARM JSON only (some CI/CD platforms, some policy engines), or when you're generating templates from code in a language that already has good JSON support but no Bicep emitter.

Use Bicep whenyou're authoring new templates by hand, especially complex ones with conditionals, loops, and multiple modules. The line-count reduction is real, and the IDE experience is significantly better. For cross-tool comparisons, see the Terraform comparison and the Pulumi comparison guides — both cover IaC tools that pursue similar ergonomics goals from different angles.

Migrating ARM JSON to Bicep with az bicep decompile

The Azure CLI ships the Bicep compiler and a reverse-direction decompiler. Run az bicep decompile --file main.json in the directory containing your ARM JSON template, and the CLI produces a main.bicep file alongside it. The output is a best-effort translation — the decompiler handles every ARM construct but the result is sometimes more verbose than hand-written Bicep, because it has to preserve the JSON structure without inferring intent.

# Forward: Bicep author -> JSON wire format
az bicep build --file main.bicep
# Produces main.json that ARM accepts directly.

# Reverse: existing JSON -> Bicep
az bicep decompile --file main.json
# Produces main.bicep — review and tidy.

# Validate the decompiled output against the same resource group
az deployment group what-if \
  --resource-group MyRg \
  --template-file main.bicep \
  --parameters @main.parameters.json

# Compare with the original JSON's plan
az deployment group what-if \
  --resource-group MyRg \
  --template-file main.json \
  --parameters @main.parameters.json

# If both what-if plans match, cut over to Bicep.
az deployment group create \
  --resource-group MyRg \
  --template-file main.bicep \
  --parameters @main.parameters.json

What to review in the decompiled Bicep:

  • String concatenationsconcat() calls often become cleaner Bicep string interpolations: 'stg${uniqueString(resourceGroup().id)}'reads better than the JSON equivalent. The decompiler does this automatically when it's safe.
  • resourceId calls — most become symbolic references (storage.id instead of resourceId('Microsoft.Storage/storageAccounts', 'mystg')). If your JSON used resourceId across templates, the decompiler may keep the function call — manually convert to existing resource declarations.
  • Nested deployments — frequently make sense as Bicep module declarations. The decompiler emits the equivalent module call automatically when the linked template is a local file.
  • Conditional resources — translate but the if() function-call pattern becomes Bicep's native if expression on the resource. Properties using if() get the same treatment.

Incremental migration strategy: decompile one template at a time, run az deployment group what-ifagainst both versions, confirm the plans match (zero diff in the proposed actions), then merge the Bicep version. The transpiler is deterministic, so once what-if agrees, the deployed resources will be identical. This guide's focus has been the JSON format — for general patterns on managing configuration as code, see JSON config files.

Key terms

ARM (Azure Resource Manager)
The Azure control plane that processes deployment templates and orchestrates resource provider calls. Every Azure operation (portal click, CLI command, Terraform apply) ultimately hits ARM.
ARM JSON template
A declarative JSON document submitted to ARM that describes the desired state of a set of Azure resources. Six top-level sections: $schema, contentVersion, parameters, variables, resources, outputs.
Bicep
Microsoft's domain-specific language for Azure deployments. Transpiles to ARM JSON before submission, so runtime behavior is identical. Recommended authoring language since 2021.
apiVersion
A date string pinning a resource declaration to a specific version of the resource provider's schema. Required on every resource entry; makes deployments deterministic across provider updates.
resourceId function
Builds a fully-qualified ARM resource ID string from type and name (plus optional subscription, resource group, child segments). Used in property assignments expecting resource IDs and in dependsOn.
reference function
Returns the runtime property bag of a deployed resource — endpoints, IPs, identity principalIds — values that only exist after creation. Requires the resource's apiVersion as a second argument.
copy property
Resource-, variable-, or property-level loop construct. Pairs with copyIndex() to read the current iteration. Modes: parallel (default), serial with optional batchSize.
linked template
A child deployment declared as a Microsoft.Resources/deployments resource whose templateLink.uri points to another JSON file. The standard ARM JSON pattern for modular deployments.

Frequently asked questions

Is ARM JSON still supported in 2026?

Yes. ARM JSON templates remain fully supported by the Azure Resource Manager deployment engine — the same engine that processes Bicep. Bicep is a transpiler that compiles to ARM JSON before submission, so anything Bicep can deploy, ARM JSON can deploy. Microsoft has not deprecated ARM JSON and has not announced an end-of-life. New Azure resource providers continue to be addressable from ARM JSON the moment they ship, because the deployment API speaks JSON natively. That said, Bicep has been the recommended authoring language since 2021. Microsoft documentation leads with Bicep examples, the Azure portal exports Bicep by default for new resources, and the developer experience (modules, type hints, simpler syntax) is meaningfully better. Use ARM JSON when you have an existing template library, when a deployment tool emits JSON only, or when you need to integrate with non-Bicep tooling. For new authoring, Bicep is the better default.

What's the difference between ARM JSON and Bicep?

Bicep is a domain-specific language that compiles to ARM JSON. The Azure Resource Manager engine only understands JSON — when you run az deployment group create --template-file main.bicep, the Azure CLI invokes the Bicep compiler, produces an in-memory ARM JSON document, and submits that to ARM. The runtime behavior is identical: the same resource providers, the same RBAC, the same activity log. What differs is the source format. Bicep gives you cleaner syntax (resource symbolic names instead of resourceId calls, : assignment instead of "value" wrappers), modules with typed inputs and outputs, IntelliSense in VS Code that knows every resource type and apiVersion, decorators for parameter constraints, and conditional expressions written as if/else rather than nested ARM functions. A 200-line ARM JSON template typically becomes 70-90 lines of Bicep with the same behavior. Bicep is purely an authoring improvement — ARM JSON remains the wire format.

Why do I need an apiVersion for each resource?

Azure resource providers ship breaking changes between API versions — new required properties, renamed fields, removed defaults, behavior changes. Pinning apiVersion in every resource declaration makes the deployment deterministic: ARM uses exactly the schema you authored against, regardless of when the template is submitted. A template written for Microsoft.Storage/storageAccounts apiVersion 2023-05-01 will keep deploying the same way even when 2025-09-01 ships with different defaults. Find current apiVersion values three ways: the Azure portal under a resource's Properties → API version, the az provider show command (az provider show --namespace Microsoft.Storage --query "resourceTypes[?resourceType=='storageAccounts'].apiVersions"), or the Azure REST API browser at learn.microsoft.com. Prefer the latest stable (non-preview) version unless you specifically need a preview feature. Avoid floating to "latest" — that pattern does not exist in ARM JSON and would defeat the determinism the field is designed to provide.

How do I reference a resource's runtime property?

Use the reference() function. resourceId() returns a static string built from subscription, resource group, type, and name — useful for IDs in dependsOn or in property assignments that expect a resource ID. reference() returns the full property bag of a deployed resource at runtime, so you can read computed values: storage account primary endpoints, public IP addresses Azure assigned after creation, managed identity principalIds, key vault URIs. Syntax: [reference(resourceId('Microsoft.Storage/storageAccounts', parameters('storageName')), '2023-05-01').primaryEndpoints.blob]. The second argument is the apiVersion — required because reference() needs to know which schema to return. Pair reference() with outputs to surface values to the caller, or with property assignments to wire two resources together (the function app reads the storage account's connection string at deploy time). For resources in the same template, ARM automatically resolves dependencies; for external resources, add dependsOn explicitly.

How do I deploy nested templates in ARM JSON?

Declare a Microsoft.Resources/deployments resource with a templateLink (pointing to a JSON file at a URL) or an inline template (the template object nested directly in the parent). templateLink suits modular templates stored in Azure Blob Storage or a public URL — the parent template references a URI, and ARM fetches and deploys it as a child deployment. Inline templates work for smaller modules that fit comfortably in the parent file. Pass parameters into the child via the parameters property of the deployments resource, and read child outputs back via reference(deploymentName).outputs.fieldName.value. Each nested deployment shows up separately in the activity log, which helps debugging. The mode property (Incremental or Complete) controls whether the child deployment adds resources or replaces the entire resource group's state — Incremental is the safer default. For deeply modular setups, Bicep modules are cleaner; for ARM JSON, linked templates remain the standard pattern.

What is the template size limit in Azure Resource Manager?

ARM enforces two limits depending on how the template arrives. Inline templates (POSTed directly in the deployment request body) are capped at 1 MB, including parameters and variables expanded. Linked templates (referenced via templateLink to a URL) are capped at 4 MB per file. A single deployment can include up to 800 resource declarations and 256 parameters; output objects are capped at 64 per deployment. When you hit the inline limit, switch to linked templates — split the monolith into a parent that orchestrates child deployments via Microsoft.Resources/deployments. When you hit the 800-resource ceiling, split across multiple parent deployments grouped by lifecycle (network resources in one, compute in another). Bicep modules compile down to nested ARM deployments under the hood, so the same limits apply — a large Bicep file produces a large ARM JSON document, and that document must fit. For most projects the limits are not a concern; they bite mainly at landing-zone scale.

How do I convert ARM JSON to Bicep?

Run az bicep decompile --file template.json. The Azure CLI ships the Bicep toolchain, and the decompile command converts an ARM JSON template into a Bicep file in the same directory. The output is a best-effort translation — it produces working Bicep for most templates, but you should review the result. Things to clean up after decompile: resourceId() and concat() calls often become string interpolations or symbolic references that read more naturally if you rename the intermediate variables; nested resources frequently make sense as Bicep modules; conditional resources translate but the if/else syntax can be tightened. Decompile is also useful in reverse: edit Bicep, run az bicep build to produce the JSON your downstream tools expect. For incremental migration, decompile one template at a time, run az deployment group what-if against both versions, confirm the plans match, then cut over. The transpiler is deterministic, so once what-if agrees, the deployed resources will be identical.

Can I use comments in ARM JSON templates?

Partially. ARM JSON officially supports JavaScript-style // line comments and /* */ block comments when the template is processed by Azure deployment tooling — the Azure CLI, PowerShell Az modules, and the portal's template editor all strip comments before submission. This is a Microsoft extension on top of strict JSON; the underlying JSON parser at the ARM API would reject comments, but the tooling sanitizes them first. There's also a metadata property that every parameter, variable, resource, and output can carry — { "metadata": { "description": "Storage account name (3-24 lowercase chars)" } } — which surfaces in the Azure portal's template UI and in az deployment group what-if. Prefer metadata for machine-readable documentation (descriptions, allowed values) and // comments for human notes on tricky logic. If you build your own pipeline that submits templates directly via REST, strip comments first or you'll get parse errors.

Further reading and primary sources