Ch. 15 · AWS

Amazon S3 Consistency, Overwrites and Access Control Explained

What S3 guarantees after a write, why concurrent writers still overwrite each other, and how bucket policies and presigned URLs grant access.

~7 min readintermediateupdated Oct 7, 2026

S3 gets treated as a filesystem in the cloud, and for many applications that’s close enough. Then someone builds a job queue out of objects, or two services update the same manifest.json, or a mobile app needs to upload a photo without AWS credentials, and the differences start to matter. This note covers the three that interviewers ask about most: what you can rely on after a write, what happens when writers collide, and how access is granted without making a bucket public.

Strong consistency, and what it doesn’t cover

Since December 2020, S3 provides strong read-after-write consistency for all objects in all Regions, at no extra cost. Concretely: once a PutObject returns success, any subsequent GetObject for that key returns the new data, and a ListObjectsV2 includes it. After a successful DeleteObject, a GetObject returns 404 NoSuchKey and the list no longer includes it. Older blog posts and some exam material still describe “eventual consistency for overwrites”, which is out of date.

Two important limits stay:

  • “Subsequent” means after the response. If a reader’s GET is in flight while a writer’s PUT is in flight, the reader may see either version. Consistency is about ordering after a write completes, not about atomic snapshots across requests.
  • It’s per object. Writing data.csv and then data.csv.done gives you consistency for each key, but no transaction across them, and a reader can’t fetch both in one atomic read. Write the data first and the marker last, and have readers wait for the marker before they read the data.

Bucket configuration (policies, versioning, lifecycle rules) is a different story: changes there can take a little time to propagate, so a test that creates a bucket policy and immediately relies on it may flake.

Objects are replaced, never edited

An S3 object is written in one piece. There’s no “seek to byte 4,000 and change it” and, for general purpose buckets, no append. Updating a 2 GB file means uploading a new 2 GB object under the same key, which replaces the old one atomically: readers see the whole old object or the whole new one, never a mix.

This shapes designs. A log or event stream should be many small objects with distinct keys (events/2026/10/07/14/part-0007.json), not one growing file. A dataset that changes a few rows a day should be partitioned so changes rewrite small objects. And because each key has its own request rate (S3 supports at least 3,500 writes and 5,500 reads per second per partitioned prefix, scaling with more prefixes), spreading load across keys is also how you scale throughput.

Large uploads should use multipart upload (the SDKs’ high-level transfer managers do it for you). A single PutObject is limited to 5 GB, and multipart lets parts retry independently. One side effect to remember: for multipart objects, the ETag is not the MD5 of the content, so don’t use it as a checksum; use the additional checksum algorithms (ChecksumSHA256 and friends) when you need integrity checks.

When two writers collide

Strong consistency doesn’t mean S3 coordinates writers. Two PUTs to the same key both succeed, and the object ends up with whichever S3 considers last. Neither writer gets an error.

Picture a manifest that lists the files in a dataset. Two ingestion workers each GET manifest.json, add their file to the list, and PUT it back. Both succeed, and one worker’s file is missing from the result: a lost update, exactly like the read-modify-write race in a database.

S3 now supports conditional writes for this:

  • If-None-Match: * on PutObject means “only create this key if it doesn’t exist.” If it does, S3 returns 412 Precondition Failed. That gives you a safe “create once” for things like lock files or idempotent job outputs.
  • If-Match: <etag> means “only overwrite if the current object’s ETag is still the one I read.” If someone changed it in between, you get 412 and you reread and retry. That’s optimistic locking for the manifest.

In boto3 the manifest update looks like this (illustrative; bucket and key are made up, and nothing here was run against AWS):

import json, boto3
from botocore.exceptions import ClientError

s3 = boto3.client("s3")
BUCKET, KEY = "acme-datasets", "sales/manifest.json"

def add_file(name, attempts=5):
    for _ in range(attempts):
        obj = s3.get_object(Bucket=BUCKET, Key=KEY)
        etag = obj["ETag"]
        manifest = json.loads(obj["Body"].read())
        manifest["files"].append(name)
        try:
            s3.put_object(Bucket=BUCKET, Key=KEY, Body=json.dumps(manifest).encode(), IfMatch=etag)
            return
        except ClientError as e:
            if e.response["Error"]["Code"] != "PreconditionFailed":
                raise
            # someone else updated the manifest; reread and try again
    raise RuntimeError("manifest is too contended; use a database for this")
python

The last line is honest advice. If many writers contend on one key, conditional writes will keep failing and retrying. At that point the manifest belongs in DynamoDB (or the design should give each writer its own key and let readers list a prefix).

Deletes are similar: deleting a key while someone writes it gives whichever operation lands last. With versioning enabled, nothing is truly lost in either case, which is covered in the versioning note.

Access: who can read and write

New buckets are private by default, have S3 Block Public Access turned on, and have Object Ownership set to “bucket owner enforced”, which disables ACLs so that every object belongs to the bucket owner and access is controlled by policies only. Leave those defaults alone unless you’re hosting genuinely public content, and even then a CloudFront distribution with origin access control is usually better than a public bucket.

With ACLs out of the way, access comes from the same evaluation as the rest of IAM:

  • Identity policies on the role your app runs as, for access within your account.
  • Bucket policies on the bucket, for cross-account access, service principals (like CloudFront or a log delivery service) and guardrails such as “deny any request that isn’t over TLS” (aws:SecureTransport) or “deny uploads without SSE-KMS”.

A frequent mistake is the resource ARN. s3:ListBucket applies to the bucket, arn:aws:s3:::acme-datasets. Object actions such as s3:GetObject and s3:PutObject apply to objects, arn:aws:s3:::acme-datasets/*. Grant GetObject on the bucket ARN and every read fails with AccessDenied. Another: without s3:ListBucket, a GET for a missing key returns 403 AccessDenied rather than 404, because S3 won’t confirm whether a key exists to someone who can’t list. That confuses people debugging “missing” files.

Presigned URLs for clients without credentials

A browser or mobile app shouldn’t hold AWS credentials, but it often needs to upload or download one specific object. A presigned URL solves it: your backend, using its own role, signs a request for one operation on one key, and hands the URL to the client. Whoever holds the URL can perform that one request until it expires.

url = s3.generate_presigned_url(
    "put_object",
    Params={"Bucket": "acme-uploads", "Key": "avatars/u_4821.jpg", "ContentType": "image/jpeg"},
    ExpiresIn=300,
)
python

The URL carries the signer’s authority, so the signer’s permissions matter: if the backend’s role can’t PutObject to that key, neither can the URL. The URL also stops working when the signing credentials expire, so a URL signed with a role session that has 10 minutes left lasts at most 10 minutes even if you asked for an hour. (The absolute maximum with SigV4 is seven days, and only with long-term credentials.) Keep expiries short, choose the key on the server rather than accepting one from the client, and for uploads where you need to cap size or restrict content type, use a presigned POST with conditions.

Interview practice: the export that sometimes looks empty

A reporting service writes exports/acct-42/report.csv, then sends the customer an email with a link. Customers occasionally download an older report. The team says “S3 is eventually consistent, add a 30-second delay before emailing.” Is that right? What’s going on?

Answer

No. S3 has been strongly consistent for reads after successful writes since 2020, so once the PUT returned, a GET for that key returns the new object. A delay hides the symptom without explaining it.

I’d look for what’s different between the write and the read. The most likely causes: the link goes through a CDN (CloudFront) that cached the previous report.csv under the same URL, so customers get the cached copy; or two report jobs for the same account ran concurrently and the older job finished second, overwriting the newer report (last writer wins); or the email is sent before the PUT actually completes (fired from a callback on upload start, or on a retry path).

Fixes follow from the cause. Write each export to a unique key (exports/acct-42/2026-10-07T14-05-11Z.csv) so there’s nothing to overwrite or cache stale, and email a presigned URL for that exact key. If only one job per account should run, make the output conditional with If-None-Match: * on the unique key, or serialise the jobs. Unique keys also make the audit trail obvious.

Keep going

More in AWS

read ✓AWS · mid

AWS S3 Versioning and Recovery Limits

How S3 versioning keeps old copies and delete markers, how to restore an overwritten or deleted file, and what versioning can't protect against.

~7 min readread →
read ✓AWS · hard

AWS Auto Scaling and Dependency Limits

How EC2 Auto Scaling reacts to load, why scaling on the wrong metric backfires, and how to cap a fleet at what its databases and APIs can take.

~7 min readread →
esc