Blob Storage

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Examples on this page target Azure CLI 2.90.0 and AzCopy 10.28 — re-verify exact flag names and behavior against the Blob Storage documentation and the az storage blob command reference before relying on them, since access-tier durations, the smart tier, and the Shared-Key-disable default have all changed within the last few release cycles.

This page goes deep on Blob Storage itself: containers and the three blob types, moving data with the portal and the CLI/AzCopy, access tiers and lifecycle management, data protection, Entra ID data-plane authorization versus Shared Key and SAS, CORS, static website hosting, a brief look at Data Lake Storage Gen2, eventing via Event Grid, the Java and Python SDKs, and pricing and troubleshooting. The account-level concerns that sit above a container — account types, redundancy, encryption, account-level networking — are covered on Storage Accounts, which this page assumes as a prerequisite: every example below reuses Bookshelf’s stbookshelf storage account created there.

Containers, blob types, naming, metadata and index tags

A storage account holds any number of containers (analogous to top-level directories, but flat — there is no real subdirectory, only blob names containing / that clients render as a hierarchy). Every blob lives in exactly one container and is one of three types, chosen when it is first created and fixed for its lifetime:

Blob type What it’s for

Block blob

The default and by far the most common type: content is uploaded as a set of blocks, then committed as one blob. Used for documents, images, video, backups, and virtually everything in this page’s examples — Bookshelf’s product-images container holds ordinary block blobs.

Append blob

Optimized for append-only writes (each write adds a new block to the end); used for log aggregation, where many clients append to the same blob concurrently.

Page blob

A collection of 512-byte pages addressed randomly; the backing store for Azure VM disks (unmanaged disks) and for random-read/write workloads. Rarely created directly in an application like Bookshelf’s.

Container and blob names are lowercase-normalized in the sense that the service itself is case-sensitive but container names are restricted to lowercase letters, numbers, and hyphens (3—​63 characters, no consecutive hyphens); blob names allow mixed case, most printable characters, and / as a path separator, up to 1,024 characters.

Two metadata mechanisms attach extra information to a blob:

Metadata

Free-form name/value pairs (for example department=catalog), returned only when a client explicitly requests them (a HEAD/GET with the metadata header) — not searchable.

Blob index tags

Structured, indexed key/value pairs (up to 10 per blob) that can be queried across an entire account with az storage blob query / the "Find blob by tags" portal search, and that lifecycle management policies and Azure Policy can filter on (Lifecycle management policies). Bookshelf tags each product-images blob with category=fiction or similar so a lifecycle rule can target only a subset of blobs.

Creating a container

In the Azure portal

  1. Open stbookshelf → Data storage → Containers → + Container.

  2. Name it product-images, choose an Anonymous access level of Private (no anonymous access) (see Anonymous (public) access, and why it should stay off), and select Create.

With the Azure CLI

export RG=rg-bookshelf
export ACCOUNT=stbookshelf
export CONTAINER=product-images

# --auth-mode login uses the caller's own Entra identity (see <<authorization>>) instead of an account key
az storage container create \
  --account-name "$ACCOUNT" \
  --name "$CONTAINER" \
  --auth-mode login \
  --public-access off

Uploading and downloading blobs

--auth-mode login on every az storage blob/az storage container command below authenticates with the caller’s own Entra ID sign-in (or the identity behind az login --identity/a service principal), authorized through the Entra data-plane roles in Authorization: Entra data roles, Shared Key, and SAS — never an account key. Omitting --auth-mode login falls back to Shared Key (or, if none is configured locally, an interactive prompt), which is exactly the legacy behavior Disabling Shared Key authorization turns off account-wide.

In the Azure portal

  1. Open stbookshelf → Data storage → Containers → product-images — this is the Storage browser, also reachable from the account’s Storage browser left-nav entry directly.

  2. Select Upload, choose one or more files (for example Bookshelf’s catalog cover images), optionally set the Access tier override and Blob index tags for this upload, and select Upload.

  3. Select a blob’s row, then Download to pull it back down, or the …​ menu for Delete, Change tier, or Generate SAS ([sas]).

With the Azure CLI

# Single file
az storage blob upload \
  --account-name "$ACCOUNT" \
  --container-name "$CONTAINER" \
  --name covers/9780000000001.jpg \
  --file ./covers/9780000000001.jpg \
  --auth-mode login \
  --tags "category=fiction"

# A whole local directory (recursive, skips blobs whose local file hasn't changed -- add --overwrite to force)
az storage blob upload-batch \
  --account-name "$ACCOUNT" \
  --destination "$CONTAINER" \
  --source ./covers \
  --auth-mode login \
  --pattern "*.jpg"

# List blobs (optionally filtered by name prefix)
az storage blob list \
  --account-name "$ACCOUNT" \
  --container-name "$CONTAINER" \
  --prefix covers/ \
  --auth-mode login \
  --output table

# Download a single blob
az storage blob download \
  --account-name "$ACCOUNT" \
  --container-name "$CONTAINER" \
  --name covers/9780000000001.jpg \
  --file ./downloaded/9780000000001.jpg \
  --auth-mode login

# Delete a blob (add --delete-snapshots include to also remove its snapshots)
az storage blob delete \
  --account-name "$ACCOUNT" \
  --container-name "$CONTAINER" \
  --name covers/9780000000001.jpg \
  --auth-mode login

AzCopy for large transfers

az storage blob upload-batch is fine for a handful of files; AzCopy is the right tool once transfers get into the thousands of files or many GiB — it parallelizes automatically, resumes interrupted jobs, and supports the same Entra-based login:

azcopy login   # opens a device-code/browser Entra sign-in, cached for subsequent commands

# One-shot copy of a local directory into the container
azcopy copy "./covers" "https://stbookshelf.blob.core.windows.net/product-images/covers" --recursive

# Keep the container in sync with a local directory, deleting blobs no longer present locally
azcopy sync "./covers" "https://stbookshelf.blob.core.windows.net/product-images/covers" --delete-destination true

# Blob-to-blob (server-side) copy, e.g. between storage accounts or across regions -- no data passes through the caller
azcopy copy \
  "https://stbookshelf.blob.core.windows.net/product-images/covers" \
  "https://stbookshelfdr.blob.core.windows.net/product-images/covers" \
  --recursive

AzCopy also accepts a container/blob SAS URL in place of azcopy login ("https://…​?<sas-token>"), useful for a script that must not carry the operator’s own Entra credentials.

Access tiers: Hot, Cool, Cold, Archive, and the smart tier

Every block blob has an access tier that trades storage cost against access/retrieval cost and, for the three coldest tiers, a minimum retention duration billed even if the blob is moved or deleted earlier:

Tier Best for Minimum duration Retrieval

Hot

Data accessed frequently (Bookshelf’s live product-images).

None

Immediate, lowest access-time latency, highest per-GiB storage cost.

Cool

Infrequently accessed data kept for at least 30 days (older order invoices).

30 days

Immediate, slightly higher per-operation/read cost than Hot.

Cold

Rarely accessed data kept for at least 90 days — cheaper storage than Cool at the cost of higher access charges.

90 days

Immediate.

Archive

Data that can tolerate hours of retrieval latency and is kept for at least 180 days (long-term compliance archives).

180 days

Not immediate — requires rehydration first (below); offline while archived.

An account can also be set to Cool or Cold as its own default access tier at creation (in addition to the per-blob tier), which only changes the tier newly-uploaded blobs land in without a tier explicitly specified.

Rehydrating an archived blob

An Archive-tier blob must be rehydrated to Hot, Cool, or Cold before it can be read; this is not instantaneous:

# Standard priority: typically completes within 15 hours (SLA up to 15 hours for objects under 10 GiB)
az storage blob set-tier \
  --account-name "$ACCOUNT" \
  --container-name "$CONTAINER" \
  --name archived-report.pdf \
  --tier Hot \
  --rehydrate-priority Standard \
  --auth-mode login

# High priority: prioritized ahead of standard-priority jobs, typically completes in under an hour for smaller blobs
az storage blob set-tier \
  --account-name "$ACCOUNT" \
  --container-name "$CONTAINER" \
  --name archived-report.pdf \
  --tier Hot \
  --rehydrate-priority High \
  --auth-mode login

While rehydrating, az storage blob show reports "rehydratePendingToTier"; the blob is not readable until the rehydration completes. High-priority rehydration costs more per GiB than Standard.

The smart tier (Hot/Cool/Cold auto-tiering)

Rather than an operator manually calling set-tier or hand-writing a lifecycle rule, smart tier observes each blob’s access pattern and automatically moves it between the Hot, Cool, and Cold capacity tiers — new data lands in Hot; an object untouched for 30 days moves to Cool, then to Cold after 90 total days of inactivity; any access moves it straight back to Hot and restarts the cycle — without early-deletion or rehydration-style charges for the moves smart tier makes itself. It requires a Standard general-purpose v2 account with zone-redundant storage (ZRS, GZRS, or RA-GZRS) and only tiers block blobs (page/append blobs are unaffected). Smart tier is an account-level default access tier setting, not a per-container switch:

az storage account update \
  --resource-group "$RG" \
  --name "$ACCOUNT" \
  --access-tier Smart

Enabling it moves every blob that doesn’t already have an explicitly set tier onto smart tier; a blob with an explicit tier stays on that tier and must have its tier cleared (or the account’s default access tier changed back) to become eligible again — once moved off smart tier onto an explicit tier, it cannot be tiered back automatically. A JSON lifecycle policy (Lifecycle management policies) still acts on smart-tier blobs for delete rules, but its tier-transition rules don’t apply to them — Microsoft’s guidance is to use lifecycle rules for deletion only on smart-tier data, and reserve day-based tier-transition rules for blobs kept on an explicit tier.

Lifecycle management policies

A lifecycle management policy is a JSON document of rules, each with a filter (name prefix and/or blob index tags) and a set of tier-transition/deletion actions keyed by number of days since last modification (or since last access, if last-access tracking is enabled):

{
  "rules": [
    {
      "enabled": true,
      "name": "product-images-tiering",
      "type": "Lifecycle",
      "definition": {
        "filters": {
          "blobTypes": ["blockBlob"],
          "prefixMatch": ["product-images/"],
          "blobIndexMatch": [
            { "name": "category", "op": "==", "value": "archived" }
          ]
        },
        "actions": {
          "baseBlob": {
            "tierToCool": { "daysAfterModificationGreaterThan": 30 },
            "tierToCold": { "daysAfterModificationGreaterThan": 90 },
            "tierToArchive": { "daysAfterModificationGreaterThan": 180 },
            "delete": { "daysAfterModificationGreaterThan": 730 }
          },
          "snapshot": {
            "delete": { "daysAfterCreationGreaterThan": 90 }
          },
          "version": {
            "delete": { "daysAfterCreationGreaterThan": 90 }
          }
        }
      }
    }
  ]
}
flowchart LR U["Blob uploaded\n(Hot)"] -->|"30 days since\nlast modification"| C["Cool"] C -->|"90 days"| K["Cold"] K -->|"180 days"| A["Archive"] A -->|"730 days: delete"| D["Deleted"] A -. "rehydrate\n(set-tier, hours)" .-> H2["Hot / Cool / Cold\n(on demand)"]

In the Azure portal

  1. Open stbookshelf → Data management → Lifecycle management → + Add a rule.

  2. On Details, name the rule (product-images-tiering) and leave Rule scope as Limit blobs with filters (or Apply to all blobs to skip filters entirely).

  3. On Base blobs, add the tier-transition/delete actions and day thresholds shown above.

  4. On Filter set, add the blob prefix (product-images/) and, if used, the blob index tag match.

  5. Select Add to save the rule; it evaluates once a day and is not instantaneous.

With the Azure CLI

az storage account management-policy create \
  --account-name "$ACCOUNT" \
  --resource-group "$RG" \
  --policy @lifecycle-policy.json

Data protection: soft delete, versioning, point-in-time restore, change feed, immutability

Feature What it protects against

Soft delete for blobs

Accidental blob deletion/overwrite — a deleted/overwritten blob is retained (recoverable with az storage blob undelete) for a configurable retention period (commonly 7—​365 days) before being permanently purged.

Soft delete for containers

Accidental container deletion — the whole container (and its blobs) is recoverable with az storage container restore within its own retention window.

Blob versioning

Every write creates a new immutable version rather than overwriting the previous one; the current version is what clients read by default, and any prior version stays addressable and restorable. This is what actually protects against overwrite once enabled — soft delete alone still lets an overwrite silently replace content.

Point-in-time restore (PITR)

Restoring a whole container (or a prefix within it) to an earlier timestamp in one operation, without walking individual blob versions by hand. Requires versioning, change feed, and container soft delete to all be enabled first, and only applies to block blobs in a general-purpose v2 account.

Change feed

An ordered, durable log of every create/update/delete against blobs in the account — consumed by downstream processing (audit trail, search indexing, replication) rather than by an end user directly; PITR depends on it.

Immutability: time-based retention

Blocking deletion/modification of a blob (or every blob matching a version-level policy) until a fixed retention period elapses — for regulatory write once, read many (WORM) requirements. A policy can be unlocked (still editable/removable) or locked (permanent — cannot be shortened or removed, only extended).

Immutability: legal hold

An indefinite hold with no expiry, removed only by an explicit administrative action — used for e-discovery or litigation holds rather than a fixed compliance schedule.

In the Azure portal

  1. Open stbookshelf → Data protection → enable Enable soft delete for blobs / …​for containers (set retention days for each), Enable versioning for blobs, Enable blob change feed, and Enable point-in-time restore for containers (requires the three above), then Save.

  2. For immutability, open the container → Access policy → Add policy under Immutable blob storage, choose Time-based retention (set the retention period, optionally Allow protected append writes) or Legal hold (enter a tag), and Lock the policy only once the retention period is confirmed correct.

With the Azure CLI

az storage account blob-service-properties update \
  --account-name "$ACCOUNT" \
  --resource-group "$RG" \
  --enable-delete-retention true --delete-retention-days 30 \
  --enable-container-delete-retention true --container-delete-retention-days 30 \
  --enable-versioning true \
  --enable-change-feed true \
  --enable-restore-policy true --restore-days 29

# Undelete an accidentally deleted blob
az storage blob undelete \
  --account-name "$ACCOUNT" \
  --container-name "$CONTAINER" \
  --name covers/9780000000001.jpg \
  --auth-mode login

# Time-based immutability policy on a container, unlocked first for review
az storage container immutability-policy create \
  --resource-group "$RG" \
  --account-name "$ACCOUNT" \
  --container-name "$CONTAINER" \
  --period 365

# Locking requires the policy's current ETag (--if-match); read it first
ETAG=$(az storage container immutability-policy show \
  --resource-group "$RG" \
  --account-name "$ACCOUNT" \
  --container-name "$CONTAINER" \
  --query etag \
  --output tsv)

az storage container immutability-policy lock \
  --resource-group "$RG" \
  --account-name "$ACCOUNT" \
  --container-name "$CONTAINER" \
  --if-match "$ETAG"

# Legal hold, independent of any time-based policy
az storage container legal-hold set \
  --resource-group "$RG" \
  --account-name "$ACCOUNT" \
  --container-name "$CONTAINER" \
  --tags litigation-2026-04

Authorization: Entra data roles, Shared Key, and SAS

Three independent mechanisms can authorize a data-plane request (upload/download/list/delete a blob), and they are not mutually exclusive unless deliberately disabled:

Entra ID data-plane roles

Every --auth-mode login command in this page relies on an Azure RBAC role assignment scoped to the storage account, container, or a management-group/subscription/resource-group above it — the same Azure RBAC mechanism covered in depth on Identity and Access. The three built-in data roles for blobs are:

Role Grants

Storage Blob Data Reader

Read and list blobs/containers — no write, delete, or ACL changes.

Storage Blob Data Contributor

Reader, plus write and delete blobs/containers — the role Bookshelf’s catalog-api identity holds on product-images (as introduced on Identity and Access).

Storage Blob Data Owner

Contributor, plus the ability to set POSIX ACLs and ownership on a hierarchical-namespace (Data Lake Gen2, Data Lake Storage Gen2 (briefly)) account — irrelevant on a flat namespace account.

A subtlety worth internalizing: an Azure RBAC management-plane role such as Owner, Contributor, or Storage Account Contributor does not, by itself, grant data-plane access — unless Shared Key is enabled (Disabling Shared Key authorization), in which case that same management-plane principal can call az storage account keys list (the listKeys action) and use the returned account key to authenticate as Shared Key, bypassing the Azure RBAC data role assignment entirely. This is precisely why disabling Shared Key matters even for an account whose data roles look correctly scoped: as long as Shared Key remains enabled, anyone who can read account keys has full data-plane access regardless of which (if any) Storage Blob Data role they hold.

IDENTITY_PRINCIPAL_ID=$(az identity show --name id-catalog-api --resource-group "$RG" --query principalId --output tsv)
ACCOUNT_ID=$(az storage account show --name "$ACCOUNT" --resource-group "$RG" --query id --output tsv)

az role assignment create \
  --assignee-object-id "$IDENTITY_PRINCIPAL_ID" \
  --assignee-principal-type ServicePrincipal \
  --role "Storage Blob Data Contributor" \
  --scope "$ACCOUNT_ID/blobServices/default/containers/$CONTAINER"

Disabling Shared Key authorization

Shared Key is the legacy account-key model: whoever holds either of the account’s two keys (or a connection string embedding one) can perform any data-plane operation, with no per-principal audit trail beyond "some holder of the key." Disabling it account-wide forces every client — including any code still using a connection string or StorageSharedKeyCredential — onto Entra ID authorization or SAS, and is the recommended default once every consumer has been migrated:

az storage account update \
  --name "$ACCOUNT" \
  --resource-group "$RG" \
  --allow-shared-key-access false

A client still configured with an account key or connection string after this change fails with an HTTP 403 (see Troubleshooting); a service-scoped SAS (below) that was itself generated from an account key also stops working the moment Shared Key is disabled, whereas a user delegation SAS keeps working since it is signed with an Entra-issued key, not an account key.

SAS types and user delegation SAS

A Shared Access Signature is a signed query string appended to a blob/container URL that grants scoped, time-limited permissions without a caller identity of its own. Three kinds exist:

SAS type Signed with

Service SAS

An account key — stops working the moment Shared Key is disabled.

Account SAS

An account key, but scoped across multiple services (Blob, Queue, Table, File) — also stops working when Shared Key is disabled.

User delegation SAS

A short-lived key obtained from Entra ID on behalf of an Entra principal that itself holds a Storage Blob Data role — keeps working with Shared Key disabled, and is the recommended SAS type going forward.

Generating a user delegation SAS is itself an Entra-authorized operation (--auth-mode login plus --as-user), never an account key:

EXPIRY=$(date -u -d "+1 hour" '+%Y-%m-%dT%H:%MZ')   # macOS: date -u -v+1H '+%Y-%m-%dT%H:%MZ'

SAS=$(az storage blob generate-sas \
  --account-name "$ACCOUNT" \
  --container-name "$CONTAINER" \
  --name covers/9780000000001.jpg \
  --permissions r \
  --expiry "$EXPIRY" \
  --as-user \
  --auth-mode login \
  --output tsv)

echo "https://$ACCOUNT.blob.core.windows.net/$CONTAINER/covers/9780000000001.jpg?$SAS"
sequenceDiagram participant Client participant Entra as Microsoft Entra ID participant CLI as Azure CLI (generate-sas --as-user) participant Storage as Blob Storage Client->>Entra: Authenticate, request OAuth access token Entra-->>Client: Access token (caller's Entra identity) Client->>CLI: Run generate-sas --as-user --auth-mode login CLI->>Entra: Request user delegation key (scoped, short-lived) Entra-->>CLI: User delegation key CLI->>CLI: Sign SAS parameters with the user delegation key CLI-->>Client: Signed SAS URL Client->>Storage: GET blob URL + SAS query string Storage->>Storage: Validate signature, expiry, and permissions against the delegation key Storage-->>Client: Blob content (if valid)

The $SAS value is a secret in its own right — a leaked SAS URL grants whatever it authorizes to anyone who has it until it expires, so it belongs in the same category as a password: never logged, never committed, and passed to a client over TLS only.

Stored access policies and revoking delegation

A stored access policy on a container lets a service SAS reference a policy id instead of embedding its own expiry/permissions, so revoking access for every SAS built from that policy is a single update to the policy (or its deletion) rather than waiting out each token’s own expiry — but this mechanism only applies to a Shared-Key service SAS, not to a user delegation SAS. A user delegation SAS is instead revoked in bulk by rotating (or explicitly revoking) the user delegation keys the account has issued:

az storage account revoke-delegation-keys \
  --name "$ACCOUNT" \
  --resource-group "$RG"

This immediately invalidates every currently-issued user delegation SAS on the account — a broader blast radius than revoking one stored access policy, so reserve it for a genuine compromise rather than routine rotation.

Anonymous (public) access, and why it should stay off

A container’s Public access level can be set to Blob (anonymous read of individual blob URLs, no container listing) or Container (anonymous read of blobs and container listing) — but this only takes effect if the account-level AllowBlobPublicAccess setting also permits it; the account setting is the master switch. Anonymous access means exactly that: anyone with (or able to guess/enumerate) a blob URL can read it, with no audit trail beyond the anonymous request log and no way to revoke access short of disabling public access entirely. Microsoft’s own default for new accounts is public access disabled, and it should stay disabled for anything that is not a deliberate, reviewed exception (a public asset that genuinely should be world-readable is usually better served by static website hosting behind Front Door/CDN with its own access controls — see Front Door and CDN — than by a public container). Bookshelf keeps product-images private and instead uses user delegation SAS URLs (Authorization: Entra data roles, Shared Key, and SAS) for any front-end image reference:

az storage account update \
  --name "$ACCOUNT" \
  --resource-group "$RG" \
  --allow-blob-public-access false

CORS

Cross-Origin Resource Sharing rules are configured per storage service (Blob, in this case) and let a browser running Bookshelf’s static front end (or another origin) call the Blob Storage REST API directly — for example a front end that uploads a catalog image straight to a container via a short-lived SAS URL, without routing bytes through catalog-api:

In the Azure portal

  1. Open stbookshelf → Settings → Resource sharing (CORS) → the Blob service tab.

  2. Add a rule: Allowed origins (https://shop.bookshelf.example), Allowed methods (GET, PUT, OPTIONS), Allowed headers / Exposed headers ( or specific headers), *Max age in seconds, then Save.

With the Azure CLI

# ARM (management-plane) command: authorized by Azure RBAC on the account, so it works with Shared Key disabled
az storage account blob-service-properties cors-rule add \
  --account-name "$ACCOUNT" \
  --resource-group "$RG" \
  --allowed-methods GET PUT OPTIONS \
  --allowed-origins "https://shop.bookshelf.example" \
  --allowed-headers "*" \
  --exposed-headers "*" \
  --max-age 3600

The data-plane az storage cors add command is deliberately not used here: it has no --auth-mode login option and authenticates only with an account key, connection string or SAS, so it fails against stbookshelf, which has Shared Key access disabled (Authorization: Entra data roles, Shared Key, and SAS). The az storage account blob-service-properties cors-rule group sets the same Blob service CORS rules through Azure Resource Manager instead.

Static website hosting

Enabling static website hosting on stbookshelf provisions a special, automatically-created $web container (the required name, dollar sign included) that serves its contents over HTTP(S) at a dedicated static-website endpoint, with a configurable index and custom 404 document:

In the Azure portal

  1. Open stbookshelf → Data management → Static website → toggle Enabled.

  2. Set Index document name (index.html) and Error document path (404.html), then Save — this creates the $web container and returns the Primary endpoint URL.

  3. Upload the Bookshelf static front end’s build output into $web the same way as any other container (Storage browser or CLI/AzCopy).

With the Azure CLI

az storage blob service-properties update \
  --account-name "$ACCOUNT" \
  --auth-mode login \
  --static-website \
  --index-document index.html \
  --404-document 404.html

az storage blob upload-batch \
  --account-name "$ACCOUNT" \
  --destination '$web' \
  --source ./bookshelf-frontend/dist \
  --auth-mode login \
  --overwrite

az storage account show \
  --name "$ACCOUNT" \
  --resource-group "$RG" \
  --query primaryEndpoints.web \
  --output tsv

The static-website endpoint is a separate hostname from the account’s regular Blob endpoint (https://stbookshelf.z##.web.core.windows.net rather than https://stbookshelf.blob.core.windows.net); a custom domain in front of it goes through Azure CDN/Front Door (Front Door and CDN) rather than a container-level setting here, since raw Blob Storage has no first-party custom-domain/TLS feature of its own. Static website hosting only serves GET/HEAD requests against files in $web; it is not a general-purpose web server (no server-side routing, redirects, or headers beyond what service-properties exposes), which is exactly the gap Front Door/CDN’s routing and caching layer fills in front of it.

Data Lake Storage Gen2 (briefly)

Data Lake Storage Gen2 is not a separate storage service — it is the hierarchical namespace (HNS) feature turned on for a general-purpose v2 (or Premium block blob) account, which changes the container/blob flat namespace into a real directory tree and adds POSIX-style Access Control Lists (ACLs) per file/directory on top of Azure RBAC. The same Blob REST API, SDKs, and (mostly) CLI commands used throughout this page still apply, plus a Data Lake-specific az storage fs/az dfs surface for directory-aware operations and ACL management. Enable it only at account creation (it cannot be toggled on an existing account):

az storage account create \
  --name stbookshelfanalytics \
  --resource-group "$RG" \
  --location "$LOCATION" \
  --sku Standard_LRS \
  --kind StorageV2 \
  --enable-hierarchical-namespace true

HNS is the right choice for big-data/analytics workloads (Spark, Synapse, Databricks, HDInsight) that benefit from atomic directory rename/move and per-file ACLs; Bookshelf’s product-images/$web containers have no such need and stay on a flat-namespace account. See Storage Accounts for the account-type and redundancy tradeoffs of choosing HNS at creation time.

Events via Event Grid

Blob Storage publishes account-level events (blob created, deleted, tier changed) to Event Grid, which fans them out to a subscriber (an Azure Function, a Logic App, an Event Hub, a webhook, or Bookshelf’s notifier worker) without polling the account. The commonly used event types are Microsoft.Storage.BlobCreated, Microsoft.Storage.BlobDeleted, and Microsoft.Storage.BlobTierChanged; a subject filter narrows a subscription to a specific container/prefix:

ACCOUNT_ID=$(az storage account show --name "$ACCOUNT" --resource-group "$RG" --query id --output tsv)

az eventgrid event-subscription create \
  --name notify-on-new-cover \
  --source-resource-id "$ACCOUNT_ID" \
  --endpoint "$NOTIFIER_ENDPOINT" \
  --included-event-types Microsoft.Storage.BlobCreated \
  --subject-begins-with "/blobServices/default/containers/$CONTAINER/blobs/covers/"

SDKs: Java (Spring Cloud Azure) and Python

Application code should reach Blob Storage through DefaultAzureCredential (backed, in Azure, by the same managed identity granted a Storage Blob Data role in Authorization: Entra data roles, Shared Key, and SAS) rather than a connection string or account key — the SDK equivalent of always passing --auth-mode login on the CLI.

Java (Spring Cloud Azure)

<dependency>
    <groupId>com.azure.spring</groupId>
    <artifactId>spring-cloud-azure-starter-storage-blob</artifactId>
</dependency>
@Service
public class ProductImageService {

    private final BlobServiceClient blobServiceClient;

    public ProductImageService(BlobServiceClient blobServiceClient) {
        this.blobServiceClient = blobServiceClient;   // auto-configured with DefaultAzureCredential
    }

    public void upload(String blobName, InputStream content, long length) {
        blobServiceClient.getBlobContainerClient("product-images")
                .getBlobClient(blobName)
                .upload(content, length, true);
    }
}

Constructing the client directly (outside Spring’s auto-configuration) is equivalent:

BlobServiceClient blobServiceClient = new BlobServiceClientBuilder()
        .endpoint("https://stbookshelf.blob.core.windows.net")
        .credential(new DefaultAzureCredentialBuilder().build())
        .buildClient();

See File Storage & Object Stores for the fuller Spring Boot picture — Resource/WritableResource abstraction, presigned URL/SAS comparisons across clouds, and integration testing against Azurite.

Python

from azure.identity import DefaultAzureCredential
from azure.storage.blob import BlobServiceClient

credential = DefaultAzureCredential()
blob_service_client = BlobServiceClient(
    account_url="https://stbookshelf.blob.core.windows.net",
    credential=credential,
)

container_client = blob_service_client.get_container_client("product-images")

with open("./covers/9780000000001.jpg", "rb") as data:
    container_client.upload_blob(name="covers/9780000000001.jpg", data=data, overwrite=True)

downloaded = container_client.download_blob("covers/9780000000001.jpg").readall()

Pricing model

Blob Storage billing has several independent drivers, each varying by tier and redundancy (LRS/ZRS/GRS/…​, covered on Storage Accounts):

  • Capacity — GiB/month stored, priced per access tier (Hot highest, Archive lowest) and per redundancy option (geo-redundant options roughly double single-region pricing).

  • Operations — per-10,000-operation charges, split into write/list operations and read operations, priced differently per tier (Archive’s operation charges are noticeably higher than Hot’s, which matters for a workload that reads Archive-tier data more than occasionally).

  • Data retrieval — Cool, Cold, and Archive charge a per-GiB retrieval fee on read that Hot does not; Archive additionally has a separate rehydration charge distinct from the retrieval fee.

  • Early deletion — deleting or moving a blob out of Cool/Cold/Archive before its minimum duration (Access tiers: Hot, Cool, Cold, Archive, and the smart tier) bills the remaining days of that minimum as if the blob had stayed.

  • Egress (outbound data transfer) — billed per GiB leaving an Azure region to the internet, waived for transfers within the same region and for inbound data; a static website or CDN-fronted origin typically dominates egress volume more than API-driven access.

  • Redundancy — LRS is cheapest; ZRS, GRS, RA-GRS, GZRS, and RA-GZRS each add cost roughly proportional to the extra copies/regions they maintain (see Storage Accounts for the full comparison).

Exact current per-unit rates vary by region and currency and change over time; treat any specific number here as indicative, not contractual — current rates are published at the Blob Storage pricing page, and the Azure pricing calculator models a specific capacity/access pattern end to end.

Troubleshooting

HTTP 403 after disabling Shared Key, with code that still uses a connection string. This is the expected, intended effect of Disabling Shared Key authorization, not a bug — any BlobServiceClient/CLI invocation still constructed from a connection string or StorageSharedKeyCredential fails once allow-shared-key-access is false. The fix is migrating that code path to DefaultAzureCredential (SDK) or --auth-mode login (CLI/ AzCopy) and an Entra data role, not re-enabling Shared Key.

SAS request fails with signature mismatch or an expiry-related 403. The most common causes are: the SAS string was URL-encoded twice (or not at all) when embedded in a query string; the resource being accessed (container vs. specific blob path) doesn’t match what the SAS was scoped to; or clock skew between the machine that generated the SAS and Azure’s own clock places the request outside the signed st/se window — since SAS validation is time-based, a client with a noticeably wrong system clock produces exactly this symptom even though the SAS itself was generated correctly. Confirm the permissions, resource scope, and expiry with az storage blob generate-sas --dry-run-style manual inspection of the decoded query string, and re-issue the SAS if in doubt.

A lifecycle policy rule isn’t firing on the expected blobs. The most common cause is a filter mismatch: blobIndexMatch requires the tag key/value to match exactly (case-sensitive), a blob whose tags were never actually set (only metadata was set, which lifecycle rules cannot see) never matches a blobIndexMatch filter, and prefixMatch is a plain string prefix, not a glob or regex, so a typo’d or trailing-slash-mismatched prefix silently matches nothing. Rules also evaluate once every 24 hours, not immediately after upload — a rule that "isn’t working" minutes after being created may simply not have run its first evaluation pass yet.

Static website returns 404 for a client-side route. Static website hosting serves the error document (404.html) for any path that doesn’t exist as a literal blob in $web — it does not rewrite arbitrary sub-paths to index.html the way a single-page-application dev server does. A pure static multi-page site should ensure every route has a matching blob; a client-side-routed SPA instead needs its 404.html to itself be (or redirect to) the SPA shell, or — for real path-based routing rules — a routing layer in front of the storage endpoint such as Front Door and CDN's rules engine.

Upload/download intermittently slow or throttled at high concurrency. A storage account enforces per-account request-rate and bandwidth limits (documented as "scalability and performance targets"); a workload approaching them sees increased latency and, eventually, HTTP 503 with a Server Busy/throttling error. AzCopy manages its own concurrency to stay under these limits by default; a hand-rolled multi-threaded uploader should do the same rather than maximizing thread count blindly.

Clean up

az group delete --name "$RG" --yes --no-wait

This removes stbookshelf (all containers, including $web, the lifecycle policy, and any immutability policies that are not locked) and every other resource created in $RG on this page. A locked time-based immutability policy blocks deletion of the blobs/container it protects until the policy’s retention period elapses — account for that before relying on this command to clean up a locked-policy demo.

References