Why architecture matters here
S3 fails on cost (wrong class), security (public bucket incidents), and eventual issues (rate limits, request patterns). Architecture matters because governance + lifecycle + access decide safety + cost.
The architecture: every piece explained
The top strip is the primary model. Bucket is namespace in a region. Storage class — Standard, IA, Glacier, Deep Archive — trades cost vs retrieval. Object is key + bytes + metadata. Strong consistency for all reads (since 2020).
The middle row is management. Versioning preserves history. Lifecycle auto-transitions + expires. Replication cross-region + same-region. Encryption — SSE-S3, SSE-KMS, or client-side.
The lower rows are governance. Access control — IAM + bucket policy + block public. Analytics + inventory for cost + audit. Ops — cost + rate limits + presigned URLs.
End-to-end flow
End-to-end: app uploads a 10 MB object to a Standard bucket. Versioned, encrypted with SSE-KMS. Lifecycle transitions to IA after 30 days, Glacier after 365. Cross-region replication mirrors to DR region. Block public access enforced. Presigned URL grants temporary read to a user. Cost audit via S3 Inventory + Athena.
The object model - key, bytes, metadata, nothing else
An S3 object is four things: a key (a UTF-8 string up to 1,024 bytes), a payload of up to 5 TB, a system-assigned ETag, and a small map of user metadata fixed at write time. There is no inode, no parent pointer, no directory entry, no permission bit. A bucket is one flat, sorted keyspace, and the slashes inside logs/2026/08/07/app.log are ordinary characters that the service assigns no structural meaning.
Folders are a client-side illusion. The console issues a list request with delimiter=/, and S3 returns the distinct key segments up to the next slash as CommonPrefixes. Nothing on the server represents a directory. Create a “folder” in the console and what you get is a zero-byte object whose key happens to end in a slash — an artifact that some tools then render as an empty file inside itself.
Three consequences follow directly. Enumeration is a paginated API call rather than a cheap metadata read, and one page carries a maximum of 1,000 keys, so walking a bucket of ten million objects is ten thousand round trips before you touch a byte of payload. Permissions are string matches: granting access to a “folder” means a wildcard against a key prefix in a policy, and a key that merely starts with the same characters is inside your grant whether you meant it or not. And nothing about a prefix is transactional — there is no operation that atomically affects the set of objects sharing one.
No rename, no append, no partial write
There is no rename API. Moving a/x to b/x is a server-side COPY followed by a DELETE: two billed requests, not atomic, and above 5 GB the copy itself must be issued as a multipart copy. This is the single largest source of surprise for anyone arriving from HDFS or POSIX. Job committers that write output under a staging prefix and rename it into place on success pay a per-file copy at commit time, which is why the S3A committers exist — they hold multipart uploads open and complete them at commit instead of copying anything.
There is no append either. Adding a line to an object means reading it, concatenating in memory, and putting the whole thing back. Range requests apply to reads only; you cannot write bytes 400–500 of an existing object. A PUT replaces the object wholesale and atomically: a concurrent reader gets the entire old object or the entire new one, never a spliced mixture. Concurrent PUTs to one key are last-writer-wins with no way to detect the race from the client.
That immutability is not a limitation bolted on; it is the assumption the rest of the design rests on. Because objects are never mutated in place, there is no locking protocol to coordinate, no torn write to recover, and versioning costs nothing conceptually — a “modification” was always the creation of a separate object.
Strong read-after-write, and the era it ended
Since December 2020, every S3 operation is strongly consistent. A successful PUT is immediately visible to any subsequent GET, a DELETE is immediately reflected in subsequent GETs and listings, and LIST returns the current state rather than a lagging snapshot. There is nothing to enable and no surcharge, in every region.
This matters more for what it retired than for what it promises. The old model gave read-after-write only for a brand-new key; overwrites, deletes, and all listings were eventually consistent. The nastiest edge was negative caching: issue a GET for a key that does not exist yet, receive a 404, then create it — and the 404 could persist for an unbounded interval, so a checked-then-written file appeared missing to the process that had just written it. Entire subsystems existed to paper over this. Hadoop's S3Guard and EMRFS consistent view maintained a DynamoDB table as an authoritative file index because a directory listing could silently omit files a job had finished writing, turning a correctness bug into a quietly incomplete result set. Both are gone, and any design document still citing S3 eventual consistency is describing a service that no longer exists.
Two things remain unchanged. Bucket-level configuration is still eventually consistent: policy, ACL, lifecycle, replication, and CORS updates propagate over seconds, so an integration test that writes a deny policy and immediately asserts a 403 is a flaky test by construction. And consistency is strictly per object — there is no transaction spanning two keys, which is exactly the gap that table formats such as Iceberg and Delta fill with a manifest.
The request path, and what it implies about speed
One GET explains most of S3's performance character. The client resolves a regional endpoint by DNS, which returns addresses drawn from a large front-end fleet. A front-end host terminates TLS, verifies the SigV4 signature, and evaluates the whole policy stack. It then resolves the key against the index — a distributed, sorted, key-range-partitioned map from key to the locations of that object's fragments — and streams the payload back from storage nodes, reconstructing it from erasure-coded shards spread across multiple Availability Zones.
Two properties of that path drive everything you can do about latency. First, metadata lookup and data transfer are separate systems with separate scaling limits. A workload of tiny objects is bounded by index operations and per-request overhead, not by bandwidth; doubling your network capacity does nothing for it. Second, time-to-first-byte has a floor in the tens of milliseconds and barely varies with object size, while time-to-last-byte scales with size. The lever is therefore concurrency, never request size: a single sequential stream will leave a fast interface mostly idle, and dozens of parallel ranged GETs against the same object will saturate it. Range-parallel reads are the standard technique, and they are what the higher-level transfer managers in the AWS SDKs and the CRT-based S3 client do on your behalf.
Prefixes, partitions, and the monotonic-key trap
The key index is partitioned by key range, and request-rate limits apply per partition, not per bucket. Each partition supports a published floor of 3,500 requests per second for PUT, COPY, POST and DELETE, and 5,500 per second for GET and HEAD. There is no documented ceiling on partition count, so aggregate throughput is a function of how many distinct key ranges your traffic spreads across — a bucket is not a throughput unit, a key range is.
S3 splits a hot range automatically, but not immediately. The split is a background reaction measured in minutes to hours. Until it finishes, the surplus comes back as 503 SlowDown, and the only correct client behaviour is exponential backoff with jitter and a bounded retry budget. Treating 503 as a hard failure, or retrying it tightly, converts a transient hotspot into an outage.
Monotonic keys are the classic way to guarantee this happens. A key that begins with a timestamp or a sequential identifier — 2026-08-07T12:00:01Z/…, or a zero-padded counter — places every new write at the moving end of the sorted keyspace, which is one partition. Adding writers does not help: they all queue behind the same ceiling, and because the hot range advances continuously, the split S3 performs is always chasing a target that has already moved. The remedy is entropy high in the key: a hash of the record identifier as the leading component, or the timestamp written in reverse. Note that this is precisely the opposite of what a query engine wants, since date-leading prefixes are what make scan pruning work. Ingest layout and query layout genuinely conflict, and the usual resolution is to write with entropy and rewrite into a query-friendly layout during compaction.
Multipart upload, and the parts nobody cleans up
A single PUT is capped at 5 GB, an order of magnitude below the 5 TB object limit. Above that you use multipart upload: CreateMultipartUpload returns an upload ID, UploadPart sends numbered parts (minimum 5 MB each except the final one, at most 10,000 parts), and CompleteMultipartUpload hands back the ordered list of part numbers and ETags so S3 can assemble the object.
Size is the least interesting reason to use it. Parts upload in parallel, which is the only way to fill a fast link. A failed part is retried by itself rather than restarting a 200 GB transfer from zero. Parts can be produced by different hosts. And the object does not exist until Complete succeeds, so the whole upload behaves as a single atomic publish — no reader ever observes a half-written object, and an abandoned upload leaves no visible object at all.
That last property hides the cost trap. An upload that is neither completed nor aborted keeps its parts in the bucket indefinitely. They are billed at the bucket's storage rate, and they are invisible to ListObjectsV2 — only ListMultipartUploads reveals them. A crashed uploader, a cancelled CI job, or a client that restarts by calling CreateMultipartUpload again instead of resuming will silently accumulate storage that never appears in any object listing and that nobody can reconcile against the bill. Every bucket that receives large uploads wants an abort rule, and it is one line of lifecycle configuration.
{
"Rules": [
{
"ID": "abort-orphaned-multipart-uploads",
"Status": "Enabled",
"Filter": {},
"AbortIncompleteMultipartUpload": { "DaysAfterInitiation": 7 }
}
]
}Durability and availability are different promises
The eleven-nines figure quoted for S3 is a design target derived from the redundancy model — erasure-coded fragments distributed across multiple Availability Zones, with continuous background verification and repair — not a measured service level. Treat it as a statement about how the storage layer is engineered, and note that it says nothing whatsoever about whether you can reach your data at any given moment.
Availability is the separate property: the probability that a request succeeds right now. It is degraded by things durability is indifferent to — a regional service event, a throttled prefix, an expired credential, a policy change that locks out the caller, a KMS key that has been disabled. The One Zone classes make the distinction concrete by trading it away: the object is still redundantly stored, but within a single Availability Zone, so the loss of that zone is data loss rather than a temporary outage.
The more important caveat is that durability protects the bytes from the infrastructure, not from you. A mistaken delete, an overwrite by a buggy job, or a lifecycle rule with an over-broad filter destroys data with the same extraordinary reliability. Versioning, replication into a different account, and Object Lock are the controls for that failure class, and they are the ones that actually save people.
Versioning and the delete marker that surprises everyone
Turn versioning on and DELETE stops deleting. A DELETE issued without a version ID inserts a delete marker as the newest version of the key. Subsequent GETs return 404 with the header x-amz-delete-marker: true, and ListObjectsV2 no longer shows the key at all. The prior version is completely untouched, and completely billed. Delete the marker by its own version ID and the object reappears exactly as it was — the best accidental-deletion recovery mechanism S3 offers, and the reason to enable versioning on anything you would miss.
The surprise arrives on the invoice. A team enables versioning, runs a cleanup that removes millions of keys, watches the object count fall to nearly zero, and finds storage cost unchanged month over month. ListObjectVersions, not ListObjectsV2, is the call that shows what is actually stored. Non-current versions only ever leave through an explicit lifecycle rule, and a second rule is needed to remove delete markers whose underlying versions have all expired — otherwise the bucket accumulates millions of markers that cost little but make every listing slower.
Two further edges. Suspending versioning deletes nothing; it only causes new writes to use the version ID null, which the next write overwrites, while every version created before suspension stays exactly where it was. And a DELETE that does specify a version ID is permanent and unrecoverable. MFA Delete exists to require a hardware factor for precisely that operation and for suspending versioning, but it can only be configured by the account root user and only through the API, which is why it is far more often cited than deployed.
{
"Rules": [
{
"ID": "prune-version-history",
"Status": "Enabled",
"Filter": { "Prefix": "app/" },
"NoncurrentVersionExpiration": {
"NoncurrentDays": 30,
"NewerNoncurrentVersions": 3
},
"Expiration": { "ExpiredObjectDeleteMarker": true }
}
]
}Lifecycle transitions and the fine print
Lifecycle rules attach to a bucket, select objects by prefix, tag, or size, and either move them down the class ladder or expire them. They run asynchronously: the rule set is evaluated roughly daily, so an object configured to expire on day 30 is deleted at some point after day 30, not at a wall-clock instant you can schedule against. Transitions only ever go down the ladder; there is no lifecycle action that promotes an object back to a hotter class.
Four traps account for nearly every lifecycle policy that costs more than it saves. Minimum billable durations: the infrequent-access classes bill 30 days, Glacier Flexible Retrieval 90, and Deep Archive 180 — delete or transition earlier and you are still charged the remainder. Per-object transition requests: each object moved is a billed request, so tiering ten million small objects incurs ten million charges against a per-GB saving that may be a rounding error. Small-object rules: lifecycle will not transition objects below 128 KB into the infrequent-access classes, and those classes bill a 128 KB minimum object size regardless of the real payload, so a 4 KB object in IA is billed as thirty-two times its size — which costs more than leaving it in Standard would have, despite the lower per-GB rate. And archive retrieval: objects in Glacier Flexible Retrieval and Deep Archive are not readable at all until a RestoreObject call produces a temporary copy — minutes to hours depending on the tier you request, and you pay for the restored copy alongside the archived one for as long as it lives.
The rule that follows from all four: compact before you tier, and model transition request cost and retrieval cost together, not the per-GB storage delta alone.
The storage-class spectrum is a cost-versus-latency curve
Read the classes as one continuous curve rather than a menu. Moving down it, the per-GB storage price falls and the cost and latency of getting the data back rise. Standard sits at the top: millisecond access, no retrieval charge, no minimum duration. Standard-IA and One Zone-IA keep millisecond access but add a per-GB retrieval charge plus the 30-day and 128 KB minimums, so they pay off only when reads are genuinely rare. Glacier Instant Retrieval keeps millisecond access at a lower storage price with a higher retrieval charge again. Glacier Flexible Retrieval gives up online access entirely in exchange for a large discount, with Expedited, Standard, and Bulk restore tiers trading money against wait time. Deep Archive is the floor: cheapest storage, restores measured in hours.
Intelligent-Tiering is the meta-class. A per-object monitoring charge buys automatic movement between access tiers with no retrieval fee, which is the right answer when the access pattern is genuinely unknown and objects are reasonably large. It is a poor answer for enormous counts of tiny objects, because the monitoring charge is levied per object and swamps any storage saving on a few kilobytes.
The decision rule is not “how old is this data”. It is the probability that you read an object in a period multiplied by what reading it would cost, weighed against the storage saved. Data that is old but read unpredictably during incidents belongs nowhere near Deep Archive.
Five overlapping access layers - where breaches live
S3 authorization is not one mechanism but five, and they compose. Block Public Access is four toggles that exist at both account and bucket scope, is evaluated ahead of everything else, and wins unconditionally — it can only remove access, never grant it. IAM identity policies attach to the calling principal. The bucket policy is the resource policy, and it is the only way to grant access to another account or to an anonymous caller. ACLs are the legacy per-object mechanism, now disabled outright when Object Ownership is set to bucket-owner-enforced. Access Point policies and VPC endpoint policies form the fifth layer, scoping access by network path or by named alias.
Evaluation is where intuition fails. Within one account, an allow in either the identity policy or the bucket policy is sufficient. Across accounts, an allow is required in both. An explicit deny anywhere in the stack terminates the evaluation. Fine-grained conditions are the sharp tool for narrowing all of this, and they get their own treatment in the article on IAM condition keys.
Public-bucket incidents almost never come from one badly written statement. They come from the combination: an ACL granting the AllUsers group while the bucket policy looks perfectly reasonable, or a policy with a wildcard principal whose conditions constrain nothing that matters, or a permission granted at the account level that nobody reviewing the bucket ever looks at. The defensive posture is structural rather than diligent — enable Block Public Access at the account level so no bucket can opt in, set bucket-owner-enforced so ACLs cease to exist as an evaluation path, and hand each application its own Access Point with a narrow policy rather than letting one bucket policy grow toward its size limit as a wall of statements nobody dares edit.
Presigned URLs are bearer capabilities
A presigned URL is an ordinary S3 request URL carrying a SigV4 signature and an expiry in its query string. Generating one is a purely local computation — no API call, no server-side record — which is why it scales freely and why there is nothing to revoke afterwards.
The security model follows from that. The URL carries the signer's authority, so whoever holds it acts as the signer until it expires; treat it as a bearer token in a string that will end up in browser history, referer headers, and logs. Individual URLs cannot be revoked. What can be changed is the signer's permission, because authorization is evaluated at request time as the intersection of the signature and the signer's current policy — take the permission away and every outstanding URL signed by that principal stops working at once.
Expiry has a trap of its own. Seven days is the SigV4 maximum, but only when signing with long-lived credentials. Sign with role session credentials, which is what any code on EC2, ECS, or Lambda actually has, and the URL dies when the session does, typically in an hour regardless of the expiry you requested. That mismatch is the usual explanation for a link that worked in testing and expired early in production. For uploads, prefer a presigned POST policy over a presigned PUT: the POST policy can bound content length and content type, while a presigned PUT constrains neither and will happily accept a 5 GB payload where you expected an avatar.
Event notifications and their delivery semantics
A bucket can publish events — object created, object removed, restore completed, replication outcome, lifecycle transition — filtered by key prefix and suffix, to SNS, SQS, Lambda, or EventBridge. This is the hook that turns a bucket into the front of a pipeline, and its semantics are weaker than most consumers assume.
Delivery is at-least-once, so consumers must be idempotent; a Lambda that increments a counter per event will overcount. Ordering is not guaranteed, and two writes to the same key can arrive reversed. The event record carries a sequencer field for exactly this: for a given key, left-padding the sequencer values to equal length and comparing them lexicographically tells you which event is later, and it is the only ordering signal available. Latency is usually seconds but has no upper bound, so a downstream step that assumes the object is already visible needs to handle its absence.
Choose the destination deliberately. Native SNS, SQS, and Lambda targets are the low-latency path but depend on the destination being consistently reachable. EventBridge costs an extra hop and buys richer content filtering, multiple targets per rule, archive and replay, and a dead-letter path — which usually wins for anything a business process depends on. One scaling note: on a bucket receiving millions of small objects, one notification per object is itself a load problem, and fanning into SQS so the consumer can batch is the standard fix.
The bill - storage, requests, egress
Three lines make up almost every S3 invoice. Storage is charged per GB-month at a rate set by the class. Requests are charged per thousand, with the mutating operations costing substantially more per request than reads. Data transfer out is charged to the internet and between regions, and is free to services in the same region reached through a gateway VPC endpoint. On top of those sit the per-class extras: retrieval charges, early-delete charges against the minimum durations, Intelligent-Tiering monitoring, replication transfer, and the per-object request charges generated by lifecycle transitions and Batch Operations.
The counterintuitive result is that for many workloads the request line dwarfs the storage line. A billion objects of 8 KB is only eight terabytes of storage, but creating them is a billion PUTs and every full pass over the dataset is another billion GETs. That single fact explains a set of otherwise unrelated recommendations: compaction is a cost optimisation and not merely a performance one, tiering tiny objects to infrequent access can lose money outright, and object count belongs on the same dashboard as bytes stored. S3 Storage Lens gives you both across an organisation, and S3 Inventory gives you the per-object detail to explain a number once it looks wrong.
Egress is the other line people discover late. Cross-region reads and cross-region replication both bill transfer, and traffic from private subnets that reaches S3 through a NAT gateway is charged per gigabyte for data that never needed to leave the AWS network at all. A gateway VPC endpoint for S3 costs nothing, and installing one is frequently the single highest-return change available in an account's storage bill.
Reading this alongside the rest of the S3 material
This article deliberately stops at the boundary of several neighbours. Key custody — SSE-S3, SSE-KMS, SSE-C, client-side encryption, envelope encryption, and S3 Bucket Keys — is covered end to end in S3 encryption options, including how to enforce encryption so no writer can opt out. The analytics-shaped use of S3, where buckets become zones, the Glue Data Catalog becomes the contract, and partition layout and file size dominate query cost, is in the AWS data lake article. Region and Availability Zone semantics, the account as a blast radius, and control-plane versus data-plane behaviour belong to the AWS overview. Condition-key mechanics for writing precise policies live in IAM conditions.
What is worth carrying away from this one is that almost every operational surprise in S3 traces back to a single design choice: it is a flat, immutable, per-object-consistent key-value store with an HTTP interface, and every filesystem-shaped intuition you bring — directories, renames, appends, directory permissions, cheap enumeration — is an abstraction someone built on top and will eventually leak.