S3 versioning and lifecycle rules work together to manage object versions, but their interaction can be surprisingly complex.

Let’s say you have a bucket with versioning enabled and a lifecycle rule set to expire objects older than 30 days. You might expect all old versions to disappear. However, what actually happens is that the lifecycle rule only marks the current versions of objects for deletion after 30 days. The previous versions stick around, silently accumulating storage costs.

Here’s how to see this in action. Imagine we have an object my-file.txt in a versioned bucket.

First, let’s upload an initial version:

aws s3 cp my-file.txt s3://my-versioned-bucket/my-file.txt --version-id initial-version-id

Now, we update the object, creating a new current version:

aws s3 cp my-file.txt s3://my-versioned-bucket/my-file.txt --version-id new-version-id

At this point, my-file.txt has two versions: initial-version-id and new-version-id. The new-version-id is the current version.

Let’s list the versions to confirm:

aws s3api list-object-versions --bucket my-versioned-bucket

You’ll see output like this:

{
    "Versions": [
        {
            "ETag": "\"abcdef1234567890\"",
            "Size": 1024,
            "StorageClass": "STANDARD",
            "Key": "my-file.txt",
            "VersionId": "new-version-id",
            "IsLatest": true,
            "LastModified": "2023-10-27T10:00:00.000Z"
        },
        {
            "ETag": "\"fedcba0987654321\"",
            "Size": 512,
            "StorageClass": "STANDARD",
            "Key": "my-file.txt",
            "VersionId": "initial-version-id",
            "IsLatest": false,
            "LastModified": "2023-10-27T09:00:00.000Z"
        }
    ],
    "Name": "my-versioned-bucket"
}

Now, let’s set up a lifecycle rule to expire objects older than 1 day (for quick testing):

aws s3api put-bucket-lifecycle-configuration --bucket my-versioned-bucket --lifecycle-configuration '{
    "Rules": [
        {
            "ID": "ExpireOldVersions",
            "Filter": {
                "Prefix": ""
            },
            "Status": "Enabled",
            "Expiration": {
                "Days": 1
            },
            "NoncurrentVersionExpiration": {
                "NoncurrentDays": 1
            }
        }
    ]
}'

Crucially, this configuration includes both Expiration (for current versions) and NoncurrentVersionExpiration (for non-current versions). If you only had Expiration and not NoncurrentVersionExpiration, the old versions would persist indefinitely.

After 1 day, if you list the object versions again:

aws s3api list-object-versions --bucket my-versioned-bucket

You might expect initial-version-id to be gone. However, if you only configured Expiration and not NoncurrentVersionExpiration, you would still see both versions. The Expiration action only applies to the current version of an object. When a current version expires, it becomes a non-current version and is not deleted. It’s simply marked as non-current.

The NoncurrentVersionExpiration action is what actually deletes these non-current versions. In our example, we’ve set NoncurrentDays to 1, so after 1 day, the initial-version-id would be marked for deletion by the lifecycle rule. S3 then handles the deletion in the background.

The true power comes from understanding that Expiration and NoncurrentVersionExpiration are distinct actions. Expiration targets the current version, and when it expires, it becomes a non-current version. NoncurrentVersionExpiration then targets these now-non-current versions. If you want to clean up old versions, you must configure NoncurrentVersionExpiration.

If you’ve only set Expiration and not NoncurrentVersionExpiration, and your bucket has accumulated many old versions, the next problem you’ll face is unexpected storage costs due to these orphaned versions.

Want structured learning?

Take the full S3 course →