Automatic TTL in Azure Cosmos DB with .NET

Temporary data is easy to create and surprisingly difficult to remove reliably. Sessions, idempotency keys, import buffers, transient integration responses, processed-message markers and short-lived audit records all have a natural end of life. Without an explicit lifecycle, they remain in the database, increase storage and indexing cost, and eventually make ordinary queries operate over data that no longer has business value.

Azure Cosmos DB for NoSQL provides automatic time-to-live (TTL) for this purpose. TTL assigns a lifetime in seconds to documents. Once a document reaches that lifetime, Cosmos DB excludes it from reads and queries and removes it in the background. No scheduled cleanup service, delete query or application-side sweep is required.

The feature is simple to enable, but several details are easy to miss. TTL is a container capability, item-level values are overrides rather than independent timers, expiration is based on the server-managed _ts property, and every update restarts the countdown. Physical deletion is asynchronous, yet an expired item is no longer returned to application queries. These details determine whether TTL behaves like a reliable lifecycle policy or an unexpected deletion mechanism.

What automatic TTL actually does

Cosmos DB stores a system-generated _ts property on every item. The value is a Unix timestamp in seconds and represents the item’s last modification time. When TTL is active, the effective expiration is conceptually:

$$ \text{expiresAt} = \text{_ts} + \text{effectiveTtlSeconds} $$

The effective TTL comes from the item or its container. After the expiration instant, the item is considered expired. It is no longer returned by point reads or queries even if the background deletion process has not physically reclaimed it yet.

That last distinction matters. TTL provides immediate logical expiration and eventual physical deletion. Applications do not need to filter expired documents manually, and they cannot use an ordinary query to recover a document during the gap before physical deletion.

TTL is useful when deletion is defined by age and a small delay in physical cleanup is acceptable. It is not a precise scheduler. A workflow that must send an email, close an order or execute a financial action at an exact instant needs an explicit scheduling mechanism and durable state transition. Expiration removes data; it does not execute domain behavior.

Container TTL and item TTL form one policy

The container property is named defaultTtl in JSON and DefaultTimeToLive in the .NET SDK. It has three meaningful states:

Container defaultTtlTTL stateBehavior for items without ttl
null or absentDisabledItems never expire; item-level ttl values are ignored
-1Enabled without a default expiryItems never expire unless they provide a positive ttl
Positive integerEnabled with a default expiryItems expire after that number of seconds unless they override it

Once TTL is enabled on the container, an item can provide its own ttl property:

  • a positive integer overrides the container lifetime;
  • -1 prevents that item from expiring; and
  • an absent property inherits the container setting.

This produces two common designs.

The first is expire by default. A container has a positive defaultTtl, such as 30 days. Most documents inherit it, while exceptional documents use another positive value or -1.

The second is retain by default, expire explicitly. The container has defaultTtl: -1. Documents remain indefinitely unless the application writes a positive item-level ttl. This is useful when one container contains several document types with different lifecycles, although substantially different access, throughput or indexing requirements may still justify separate containers.

An item-level ttl does nothing while container TTL is disabled. Writing the property alone is therefore not enough. Zero is not a useful policy value; application validation should accept only -1 or a positive number of seconds.

Create a container with automatic TTL

The following .NET SDK example creates a session container with a 30-day default lifetime. TTL values use whole seconds and are represented by a 32-bit integer.

 1using Microsoft.Azure.Cosmos;
 2
 3TimeSpan defaultRetention = TimeSpan.FromDays(30);
 4int defaultTtlSeconds = checked((int)defaultRetention.TotalSeconds);
 5
 6ContainerProperties properties = new ContainerProperties(
 7    id: "sessions",
 8    partitionKeyPath: "/tenantId")
 9{
10    DefaultTimeToLive = defaultTtlSeconds
11};
12
13ContainerResponse response = await database.CreateContainerIfNotExistsAsync(
14    properties,
15    throughput: 400,
16    requestOptions: null,
17    cancellationToken: cancellationToken);
18
19Container container = response.Container;

The partition key still needs to follow the workload’s access and scale pattern. TTL does not make a poor partition key safe, and expiration is not a substitute for partition-aware queries.

For an opt-in expiry model, only the container setting changes:

1ContainerProperties properties = new ContainerProperties(
2    id: "integration-data",
3    partitionKeyPath: "/tenantId")
4{
5    DefaultTimeToLive = -1
6};

This enables TTL but gives documents no finite default lifetime. Any document that should expire must carry a positive ttl property.

Infrastructure as code is usually the right owner for production container configuration. Application startup code that can create or replace containers requires management permissions that normal data-plane workloads often should not have. The SDK examples remain useful for local provisioning, tests and administrative tools.

Enable or change TTL on an existing container

An existing container can be updated by reading its current properties, changing DefaultTimeToLive and replacing the container definition.

 1using Microsoft.Azure.Cosmos;
 2
 3ContainerResponse currentResponse = await container.ReadContainerAsync(
 4    cancellationToken: cancellationToken);
 5
 6ContainerProperties currentProperties = currentResponse.Resource;
 7currentProperties.DefaultTimeToLive = checked(
 8    (int)TimeSpan.FromDays(14).TotalSeconds);
 9
10ContainerResponse updatedResponse = await container.ReplaceContainerAsync(
11    currentProperties,
12    cancellationToken: cancellationToken);

This is a high-impact change. Enabling a positive default on an existing container also applies to existing items that do not have an item-level override. Their expiration is calculated from their current _ts, not from the moment the container setting changes. Old items can therefore become immediately eligible for expiration.

A safe rollout starts by inspecting item ages and existing ttl properties, estimating how much data will expire, validating backup and restore requirements, and confirming that no permanent document type shares the default unintentionally. Setting DefaultTimeToLive to null disables TTL; setting it to -1 keeps the feature active while removing finite default expiration.

Replacing container properties should use the latest response rather than constructing an incomplete replacement object. Container configuration includes indexing, unique keys, conflict resolution and other settings that should not be accidentally changed by a TTL-only operation. Infrastructure deployment also needs the same care when it manages the complete resource declaration.

Add item-level TTL in a .NET document model

The item property must be serialized as ttl. The _ts system property can also be mapped when the application needs to display or calculate expiration metadata.

 1using System.Text.Json.Serialization;
 2
 3public sealed record SessionDocument
 4{
 5    [JsonPropertyName("id")]
 6    public required string Id { get; init; }
 7
 8    [JsonPropertyName("tenantId")]
 9    public required string TenantId { get; init; }
10
11    [JsonPropertyName("userId")]
12    public required string UserId { get; init; }
13
14    [JsonPropertyName("createdAtUtc")]
15    public required DateTimeOffset CreatedAtUtc { get; init; }
16
17    [JsonPropertyName("ttl")]
18    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
19    public int? TimeToLiveSeconds { get; init; }
20
21    [JsonPropertyName("_ts")]
22    public long LastModifiedUnixSeconds { get; init; }
23}

The attributes above assume that the Cosmos client uses the SDK’s System.Text.Json integration. The serializer should be configured once at client construction so null item overrides are omitted rather than written as ambiguous payload values:

 1using Microsoft.Azure.Cosmos;
 2using System.Text.Json;
 3using System.Text.Json.Serialization;
 4
 5JsonSerializerOptions serializerOptions = new JsonSerializerOptions(
 6    JsonSerializerDefaults.Web)
 7{
 8    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
 9};
10
11CosmosClientOptions clientOptions = new CosmosClientOptions
12{
13    UseSystemTextJsonSerializerWithOptions = serializerOptions
14};
15
16CosmosClient cosmosClient = new CosmosClient(
17    connectionString,
18    clientOptions);

Applications that retain the SDK’s default Newtonsoft.Json serializer need the equivalent Newtonsoft.Json property attributes instead. Mixing serializer-specific attributes can make TimeToLiveSeconds appear in stored JSON under the CLR property name, in which case Cosmos DB does not recognize it as the reserved ttl property.

LastModifiedUnixSeconds is populated by Cosmos DB on responses. The application should not attempt to assign _ts when creating an item. The server owns system properties.

An item that inherits the container default leaves TimeToLiveSeconds as null:

 1SessionDocument session = new SessionDocument
 2{
 3    Id = Guid.NewGuid().ToString("N"),
 4    TenantId = tenantId,
 5    UserId = userId,
 6    CreatedAtUtc = timeProvider.GetUtcNow(),
 7    TimeToLiveSeconds = null
 8};
 9
10ItemResponse<SessionDocument> createResponse = await container.CreateItemAsync(
11    session,
12    new PartitionKey(session.TenantId),
13    cancellationToken: cancellationToken);

A short-lived session can override the 30-day container default:

1TimeSpan shortRetention = TimeSpan.FromHours(2);
2
3SessionDocument shortSession = session with
4{
5    Id = Guid.NewGuid().ToString("N"),
6    TimeToLiveSeconds = checked((int)shortRetention.TotalSeconds)
7};

With TTL enabled on the container, setting TimeToLiveSeconds to -1 makes one item persistent. That override should be deliberate and easy to find in code. Using -1 as a generic fallback for invalid configuration can silently retain data that was expected to disappear.

Read the container TTL configuration

The effective policy cannot be determined from an item alone because a missing item-level ttl inherits the container setting. Reading the container properties exposes the default:

 1ContainerResponse response = await container.ReadContainerAsync(
 2    cancellationToken: cancellationToken);
 3
 4int? defaultTtlSeconds = response.Resource.DefaultTimeToLive;
 5
 6string ttlMode = defaultTtlSeconds switch
 7{
 8    null => "disabled",
 9    -1 => "enabled-without-default-expiration",
10    > 0 => "enabled-with-default-expiration",
11    _ => "invalid"
12};

Container metadata changes far less frequently than item reads, so production code should not call ReadContainerAsync for every document. Configuration can be known from infrastructure, loaded during startup or cached with an appropriate refresh policy. An administrative health check can compare the deployed property with the expected value and report configuration drift.

The Azure portal exposes the same container setting in Data Explorer under the container’s Scale & Settings view. Individual JSON documents show an explicit ttl override when one exists, while system properties such as _ts can be inspected in the item representation. The portal is useful for verification and troubleshooting; repeatable environment configuration should remain in infrastructure code rather than depend on manual changes.

Read item TTL and calculate the remaining lifetime

A normal point read returns the serialized item-level ttl and server-generated _ts. The effective expiration can then be calculated together with the container default.

 1ItemResponse<SessionDocument> response = await container.ReadItemAsync<SessionDocument>(
 2    id,
 3    new PartitionKey(tenantId),
 4    cancellationToken: cancellationToken);
 5
 6SessionDocument document = response.Resource;
 7TtlState ttlState = TtlCalculator.Calculate(
 8    containerDefaultTtlSeconds,
 9    document.TimeToLiveSeconds,
10    document.LastModifiedUnixSeconds,
11    timeProvider.GetUtcNow());

A small calculator keeps all three container states and both item override states explicit:

 1public sealed record TtlState(
 2    bool IsEnabled,
 3    bool Expires,
 4    int? EffectiveTtlSeconds,
 5    DateTimeOffset? ExpiresAtUtc,
 6    TimeSpan? Remaining);
 7
 8public static class TtlCalculator
 9{
10    public static TtlState Calculate(
11        int? containerDefaultTtlSeconds,
12        int? itemTtlSeconds,
13        long lastModifiedUnixSeconds,
14        DateTimeOffset nowUtc)
15    {
16        if (containerDefaultTtlSeconds is null)
17        {
18            return new TtlState(
19                IsEnabled: false,
20                Expires: false,
21                EffectiveTtlSeconds: null,
22                ExpiresAtUtc: null,
23                Remaining: null);
24        }
25
26        int effectiveTtlSeconds = itemTtlSeconds
27            ?? containerDefaultTtlSeconds.Value;
28
29        if (effectiveTtlSeconds == -1)
30        {
31            return new TtlState(
32                IsEnabled: true,
33                Expires: false,
34                EffectiveTtlSeconds: -1,
35                ExpiresAtUtc: null,
36                Remaining: null);
37        }
38
39        if (effectiveTtlSeconds <= 0)
40        {
41            throw new InvalidOperationException(
42                "An effective TTL must be -1 or a positive number of seconds.");
43        }
44
45        DateTimeOffset lastModifiedUtc =
46            DateTimeOffset.FromUnixTimeSeconds(lastModifiedUnixSeconds);
47        DateTimeOffset expiresAtUtc =
48            lastModifiedUtc.AddSeconds(effectiveTtlSeconds);
49        TimeSpan remaining = expiresAtUtc - nowUtc;
50
51        return new TtlState(
52            IsEnabled: true,
53            Expires: true,
54            EffectiveTtlSeconds: effectiveTtlSeconds,
55            ExpiresAtUtc: expiresAtUtc,
56            Remaining: remaining > TimeSpan.Zero
57                ? remaining
58                : TimeSpan.Zero);
59    }
60}

The calculation is useful for an administration view or diagnostics, but it does not replace Cosmos DB as the authority. Client and server clocks can differ, a concurrent update can change _ts, and the item may expire between calculation and the next operation. Code must still handle 404 Not Found on later reads.

Query TTL metadata

System property _ts and the user-visible ttl property can be projected in a Cosmos DB query. The following query returns documents that define an item-level override within one tenant:

 1using Microsoft.Azure.Cosmos;
 2
 3public sealed record ItemTtlProjection(
 4    string Id,
 5    int Ttl,
 6    long LastModifiedUnixSeconds);
 7
 8QueryDefinition query = new QueryDefinition(
 9    """
10    SELECT
11        item.id,
12        item.ttl,
13        item._ts AS lastModifiedUnixSeconds
14    FROM item
15    WHERE IS_DEFINED(item.ttl)
16    """);
17
18QueryRequestOptions options = new QueryRequestOptions
19{
20    PartitionKey = new PartitionKey(tenantId),
21    MaxItemCount = 100
22};
23
24FeedIterator<ItemTtlProjection> iterator =
25    container.GetItemQueryIterator<ItemTtlProjection>(
26        query,
27        requestOptions: options);
28
29while (iterator.HasMoreResults)
30{
31    FeedResponse<ItemTtlProjection> page =
32        await iterator.ReadNextAsync(cancellationToken);
33
34    foreach (ItemTtlProjection item in page)
35    {
36        Console.WriteLine(
37            $"{item.Id}: ttl={item.Ttl}, _ts={item.LastModifiedUnixSeconds}");
38    }
39}

This query does not return expired items. It also omits documents that inherit the container default because they have no item-level ttl property. A complete lifecycle report must combine each item’s _ts and optional override with the known container default.

Querying all partitions merely to monitor expiration is usually wasteful. Lifecycle metrics are better derived from write policies and bounded operational queries. When an administration workflow does need document-level expiry information, it should stay partition-scoped, paginated and measured through request charges.

The ttl path does not need to be indexed for Cosmos DB to expire an item. It only needs indexing when application queries filter or order by that property. Removing unnecessary paths from the indexing policy can reduce write cost for high-volume ephemeral containers, but every exclusion should be validated against actual query patterns.

Updates restart the TTL countdown

TTL is relative to _ts, and _ts changes whenever the item is modified. Replacing a document, patching an ordinary property or patching the ttl value itself restarts the countdown from the new server modification time.

 1PatchOperation[] operations =
 2[
 3    PatchOperation.Set("/lastAccessedAtUtc", timeProvider.GetUtcNow()),
 4    PatchOperation.Set("/ttl", checked((int)TimeSpan.FromHours(2).TotalSeconds))
 5];
 6
 7ItemResponse<SessionDocument> response =
 8    await container.PatchItemAsync<SessionDocument>(
 9        id,
10        new PartitionKey(tenantId),
11        operations,
12        cancellationToken: cancellationToken);

For a sliding session, this behavior may be exactly the requirement: activity extends the lifetime. It still means that every extension is a database write with RU, latency and concurrency cost. Updating on every request can create a hot item and unnecessary write load. A coarser refresh interval or a separate session store may be more appropriate for high-traffic authentication sessions.

For fixed business expiration, the reset can be wrong. Consider a verification request that must expire at 2026-08-14T12:00:00Z regardless of later metadata changes. A static 24-hour ttl moves that deadline every time the document is updated.

The robust model stores the absolute expiry as domain data and recalculates TTL whenever the item is written:

 1DateTimeOffset nowUtc = timeProvider.GetUtcNow();
 2DateTimeOffset expiresAtUtc = verificationRequest.ExpiresAtUtc;
 3TimeSpan remaining = expiresAtUtc - nowUtc;
 4
 5if (remaining <= TimeSpan.Zero)
 6{
 7    throw new InvalidOperationException("The verification request has expired.");
 8}
 9
10int ttlSeconds = checked((int)Math.Ceiling(remaining.TotalSeconds));
11
12VerificationRequestDocument updatedDocument = verificationRequest with
13{
14    Status = "pending-delivery",
15    TimeToLiveSeconds = ttlSeconds
16};

The explicit ExpiresAtUtc property is the business contract. Cosmos TTL is the automatic physical cleanup mechanism. Keeping both prevents a storage implementation detail from becoming the only representation of a domain deadline.

What happens after expiration

Once the server-calculated lifetime has elapsed, a point read behaves as if the item does not exist:

 1using System.Net;
 2using Microsoft.Azure.Cosmos;
 3
 4try
 5{
 6    ItemResponse<SessionDocument> response =
 7        await container.ReadItemAsync<SessionDocument>(
 8            id,
 9            new PartitionKey(tenantId),
10            cancellationToken: cancellationToken);
11
12    return response.Resource;
13}
14catch (CosmosException exception)
15    when (exception.StatusCode == HttpStatusCode.NotFound)
16{
17    return null;
18}

The same 404 also represents an unknown identifier or wrong partition key. Application behavior should normally treat all three cases as absent unless the domain maintains separate state elsewhere.

Physical deletion happens later as a background operation. With provisioned throughput, Cosmos DB uses request units left over from foreground work for TTL deletion. A busy container can therefore retain physically expired data longer, while logical reads still hide it. In serverless accounts, TTL background deletion consumes request units and contributes to cost.

TTL deletion is also not a general-purpose event source. A workflow must not assume that the ordinary latest-version change feed will produce a delete event for every expired item. When downstream behavior depends on expiration, it needs an explicit event or scheduler. Change feed modes that include deletes have separate account, backup, retention and processing constraints and should be evaluated as an architecture choice rather than inferred from TTL.

Concurrency and lifecycle changes

Changing an item’s TTL is a state change and should follow the same optimistic-concurrency rules as any other update. Without an ETag condition, a retention update can overwrite or race with a business update.

 1ItemRequestOptions requestOptions = new ItemRequestOptions
 2{
 3    IfMatchEtag = currentETag
 4};
 5
 6PatchOperation[] operations =
 7[
 8    PatchOperation.Set("/ttl", newTtlSeconds)
 9];
10
11ItemResponse<SessionDocument> response =
12    await container.PatchItemAsync<SessionDocument>(
13        id,
14        new PartitionKey(tenantId),
15        operations,
16        requestOptions,
17        cancellationToken);

A 412 Precondition Failed means the document changed after it was read. The caller must load the current version and decide whether the lifecycle change is still valid. Blind retries can extend or shorten the lifetime of a newer business state.

Bulk retention migrations need additional caution. Every patch updates _ts, so a migration that merely adds the same TTL value also establishes a new expiration base. If the intention is to preserve an original absolute deadline, each document needs a stored deadline from which the remaining seconds can be recalculated.

Test TTL without relying on exact deletion timing

TTL behavior has two independently testable parts.

Application unit tests should cover policy calculation without Cosmos DB. Given a container default, optional item override, _ts value and current time, the expected effective TTL and expiry instant are deterministic. TimeProvider makes the current time explicit.

Integration tests should use a short positive TTL and verify the externally visible contract:

  1. Create an item and confirm that a point read succeeds.
  2. Read the returned _ts and verify the calculated expiry window.
  3. Wait beyond the configured lifetime with a bounded retry policy.
  4. Confirm that the point read eventually returns 404 and queries omit the item.

The test should not assert when physical storage is reclaimed. That implementation detail is asynchronous and depends on available throughput. Emulator behavior is valuable for functional tests but is not a substitute for measuring production RU consumption or cleanup timing in Azure.

Long-running tests should remain a small integration-test slice rather than slowing every unit-test run. Most lifecycle defects occur in the application’s policy mapping and update behavior, both of which can be tested without waiting for real time.

Operational and security considerations

TTL reduces the amount of active data, but it is not a complete retention or compliance solution.

Backups can retain a document beyond its live-container TTL according to the account’s backup policy. Analytical replicas, exports, downstream consumers and application logs may hold copies independently. A deletion requirement has to cover every copy rather than assuming that expiration in one container removes the data everywhere.

TTL also does not provide write-once-read-many protection, a legal hold workflow or proof that a document existed. An item with ttl: -1 can be retained, but applying that override modifies the document and resets _ts. Regulated retention needs explicit access control, backup policy, administrative separation and evidence procedures in addition to automatic cleanup.

Monitoring should focus on configuration drift, write and delete request-unit consumption, storage growth, throttling and unexpected 404 rates. Logging complete expired documents to diagnose cleanup would defeat data minimization. Stable identifiers, document types and policy categories are usually sufficient operational context.

When automatic TTL is the right tool

Automatic TTL is a strong fit for data whose value is inherently temporary:

  • sessions and short-lived authentication artifacts;
  • idempotency and deduplication records;
  • cache-like integration responses;
  • temporary imports and processing buffers;
  • completed job markers;
  • transient device or telemetry documents; and
  • audit or security records with an explicit retention policy.

It is a poor fit when deletion must trigger domain behavior, happen at an exact second, wait for an external approval or preserve evidence indefinitely. It also needs care when ordinary updates should not extend lifetime. In those cases, an explicit ExpiresAtUtc domain property, a scheduler, a lifecycle process or a different storage design may be required alongside or instead of TTL.

Conclusion

Automatic TTL turns document age into a database-managed lifecycle. A container enables the feature and defines the default, an item can override that default, and Cosmos DB calculates expiration from the server-maintained _ts timestamp. Expired documents immediately disappear from normal reads while physical deletion proceeds asynchronously in the background.

The safe implementation keeps those mechanics visible. Container configuration is read and validated, item models map ttl and _ts explicitly, remaining lifetime is calculated from the effective policy, updates account for the reset of _ts, and application code handles 404 as a normal race around expiration.

TTL works best as cleanup infrastructure behind a clear data-retention model. It removes temporary data efficiently, but it does not replace domain deadlines, scheduled work, backup governance, compliance controls or deliberate partition and indexing design.

For the platform details and configuration options, see the Azure Cosmos DB documentation and the .NET SDK TTL configuration guide .


Let's Work Together

Looking for an experienced Platform Architect or Engineer for your next project? Whether it's cloud migration, platform modernization or building new solutions from scratch - I'm here to help you succeed.

New Platforms
Modernization
Training & Consulting