Same S3 Bytes Can Produce Different ETags shown with one file splitting into single and multipart upload receipts.
Last edited on August 13, 2026

Upload one file with a single PUT, upload the same bytes in two parts, and an S3-compatible store can return two different ETags. That difference is expected: a multipart ETag describes the upload’s part structure, not the MD5 of the complete file. Treating every ETag as a file checksum can therefore create false corruption alarms, broken deduplication, or false confidence.

This guide is for developers and storage operators who understand shell basics and S3 buckets but want a reproducible integrity rule. The lab runs MinIO on loopback, creates deterministic secret-free data, performs both upload shapes, recalculates the multipart ETag, downloads both objects, and proves their full SHA-256 values match. It does not contact production storage or change a real bucket.

Amazon’s current S3 integrity documentation makes the boundary explicit: a single-part ETag can equal the content MD5 under limited conditions, while a multipart ETag is built from the part digests and ends with the part count. Encryption and copy paths add more exceptions. The safe default is simple: do not use ETag as a universal whole-file hash.

Separate Object Identity From Transfer Shape

Three values answer three different questions:

  • A full-object SHA-256 answers whether the retrieved bytes match the expected artifact.
  • An S3 checksum plus its checksum type can prove what the service validated during upload or download. Multipart SHA checksums may be composite rather than full-object values.
  • An ETag identifies a stored object version for HTTP and S3 operations, but its relationship to content depends on upload method, encryption, and implementation.

This identity-versus-label boundary also appears in container registries: Voxfor’s OCI platform digest inspection workflow shows why a convenient top-level reference is not automatically the exact artifact digest a verifier needs.

Our strongest same-intent benchmark was a current DevelopersIO reproduction that uploads identical bytes by both paths and compares ETag, checksum type, and downloaded SHA-256. This guide adds an isolated S3-compatible lab, exact multipart recalculation, a corrupted-download negative control, marker-bound cleanup, and a compact pass/fail receipt.

A deterministic 12 MiB source drives the experiment: five MiB of A bytes followed by seven MiB of B bytes. That makes two legal multipart parts without relying on a client’s hidden threshold. Start only when the exact lab path is absent, pin the downloaded tools by their published SHA-256 files, and bind both MinIO ports to loopback.

set -euo pipefail
LAB=/tmp/voxfor-s3-etag-158
MARKER="$LAB/.voxfor-s3-etag-lab"
MINIO_PORT=19020
CONSOLE_PORT=19021
BUCKET=voxfor-etag-evidence
ACCESS_KEY=voxforlab
SECRET_KEY='VoxforLabOnly-158!'
[[ ! -e "$LAB" ]] || { printf 'Refusing existing path: %s\n' "$LAB" >&2; exit 9; }
install -d -m 0700 "$LAB/bin" "$LAB/data" "$LAB/receipts"
printf '%s\n' voxfor-s3-etag-lab-v1 > "$MARKER"

curl -fsSL https://dl.min.io/server/minio/release/linux-amd64/minio -o "$LAB/bin/minio"
curl -fsSL https://dl.min.io/server/minio/release/linux-amd64/minio.sha256sum -o "$LAB/bin/minio.sha256sum"
curl -fsSL https://dl.min.io/client/mc/release/linux-amd64/mc -o "$LAB/bin/mc"
curl -fsSL https://dl.min.io/client/mc/release/linux-amd64/mc.sha256sum -o "$LAB/bin/mc.sha256sum"
(cd "$LAB/bin" && printf '%s  minio\n' "$(awk 'NR==1{print $1}' minio.sha256sum)" | sha256sum -c -)
(cd "$LAB/bin" && printf '%s  mc\n' "$(awk 'NR==1{print $1}' mc.sha256sum)" | sha256sum -c -)
chmod 700 "$LAB/bin/minio" "$LAB/bin/mc"
python3 -m venv "$LAB/venv"
"$LAB/venv/bin/pip" -q install --disable-pip-version-check awscli==1.46.0

MINIO_ROOT_USER="$ACCESS_KEY" MINIO_ROOT_PASSWORD="$SECRET_KEY" \
  "$LAB/bin/minio" server --address "127.0.0.1:$MINIO_PORT" \
  --console-address "127.0.0.1:$CONSOLE_PORT" "$LAB/data" >"$LAB/minio.log" 2>&1 &
MINIO_PID=$!
printf '%s\n' "$MINIO_PID" > "$LAB/minio.pid"
awk '{print $22}' "/proc/$MINIO_PID/stat" > "$LAB/minio.start"
for attempt in $(seq 1 60); do
  curl -fsS "http://127.0.0.1:$MINIO_PORT/minio/health/ready" >/dev/null 2>&1 && break
  sleep 0.25
done
curl -fsS "http://127.0.0.1:$MINIO_PORT/minio/health/ready" >/dev/null
"$LAB/bin/mc" alias set lab "http://127.0.0.1:$MINIO_PORT" "$ACCESS_KEY" "$SECRET_KEY" >/dev/null
"$LAB/bin/mc" mb "lab/$BUCKET" >/dev/null
python3 - <<'PY'
from pathlib import Path
root=Path('/tmp/voxfor-s3-etag-158')
(root/'part-1.bin').write_bytes(b'A'*(5*1024*1024))
(root/'part-2.bin').write_bytes(b'B'*(7*1024*1024))
with (root/'source.bin').open('wb') as out:
    out.write((root/'part-1.bin').read_bytes())
    out.write((root/'part-2.bin').read_bytes())
PY
SOURCE_MD5=$(md5sum "$LAB/source.bin" | awk '{print $1}')
SOURCE_SHA=$(sha256sum "$LAB/source.bin" | awk '{print $1}')
printf 'source_bytes=%s source_md5=%s source_sha256=%s\n' \
  "$(stat -c %s "$LAB/source.bin")" "$SOURCE_MD5" "$SOURCE_SHA" | tee "$LAB/source.receipt"

Synthetic credentials are valid only on the owned loopback endpoint. On Amazon S3 or another provider, use a short-lived role or approved secret mechanism; never paste production keys into a script, shell history, article, or receipt.

Use a Single PUT as a Narrow Control

As narrow counterevidence, the single upload is useful without becoming a general rule. In this plaintext MinIO lab, put-object returns an ETag equal to the complete file’s MD5. Amazon documents similar behavior for qualifying single-part PUT objects, but SSE-KMS, SSE-C, multipart upload, part copy, and some implementation details break the shortcut.

set -euo pipefail
LAB=/tmp/voxfor-s3-etag-158
MINIO_PORT=19020
BUCKET=voxfor-etag-evidence
grep -qx 'voxfor-s3-etag-lab-v1' "$LAB/.voxfor-s3-etag-lab"
export AWS_ACCESS_KEY_ID=voxforlab AWS_SECRET_ACCESS_KEY='VoxforLabOnly-158!'
export AWS_DEFAULT_REGION=us-east-1 AWS_EC2_METADATA_DISABLED=true
AWS=("$LAB/venv/bin/aws" --endpoint-url "http://127.0.0.1:$MINIO_PORT" s3api)
SOURCE_MD5=$(md5sum "$LAB/source.bin" | awk '{print $1}')
SOURCE_SHA=$(sha256sum "$LAB/source.bin" | awk '{print $1}')
"${AWS[@]}" put-object --bucket "$BUCKET" --key single.bin \
  --body "$LAB/source.bin" --metadata "full-sha256=$SOURCE_SHA" > "$LAB/receipts/single-put.json"
"${AWS[@]}" head-object --bucket "$BUCKET" --key single.bin > "$LAB/receipts/single-head.json"
SINGLE_ETAG=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["ETag"].strip("\\\""))' "$LAB/receipts/single-head.json")
[[ "$SINGLE_ETAG" == "$SOURCE_MD5" ]]
printf 'single_etag=%s single_matches_full_md5=yes\n' "$SINGLE_ETAG" | tee "$LAB/single.receipt"

Do not promote that successful equality into policy. MinIO’s ETag implementation notes enumerate multipart and encryption exceptions, while Amazon’s multipart overview says the combined ETag is not necessarily an MD5 of the object data.

Upload the Same Bytes as Two Explicit Parts

Multipart upload has three phases: create a session, upload numbered parts, then complete the session with the exact returned part ETags. The first part must normally be at least 5 MiB; the final part may be smaller. Here both meet the minimum, and their concatenation is byte-for-byte equal to source.bin.

set -euo pipefail
LAB=/tmp/voxfor-s3-etag-158
MINIO_PORT=19020
BUCKET=voxfor-etag-evidence
grep -qx 'voxfor-s3-etag-lab-v1' "$LAB/.voxfor-s3-etag-lab"
export AWS_ACCESS_KEY_ID=voxforlab AWS_SECRET_ACCESS_KEY='VoxforLabOnly-158!'
export AWS_DEFAULT_REGION=us-east-1 AWS_EC2_METADATA_DISABLED=true
AWS=("$LAB/venv/bin/aws" --endpoint-url "http://127.0.0.1:$MINIO_PORT" s3api)
SOURCE_SHA=$(sha256sum "$LAB/source.bin" | awk '{print $1}')
UPLOAD_ID=$("${AWS[@]}" create-multipart-upload --bucket "$BUCKET" --key multipart.bin \
  --metadata "full-sha256=$SOURCE_SHA" --query UploadId --output text)
printf '%s\n' "$UPLOAD_ID" > "$LAB/upload.id"
"${AWS[@]}" upload-part --bucket "$BUCKET" --key multipart.bin --part-number 1 \
  --upload-id "$UPLOAD_ID" --body "$LAB/part-1.bin" > "$LAB/receipts/part-1.json"
"${AWS[@]}" upload-part --bucket "$BUCKET" --key multipart.bin --part-number 2 \
  --upload-id "$UPLOAD_ID" --body "$LAB/part-2.bin" > "$LAB/receipts/part-2.json"
python3 - "$LAB/receipts/part-1.json" "$LAB/receipts/part-2.json" "$LAB/complete.json" <<'PY'
import json,sys
parts=[{'ETag':json.load(open(path))['ETag'],'PartNumber':n}
       for n,path in enumerate(sys.argv[1:3],1)]
json.dump({'Parts':parts},open(sys.argv[3],'w'))
PY
"${AWS[@]}" complete-multipart-upload --bucket "$BUCKET" --key multipart.bin \
  --upload-id "$UPLOAD_ID" --multipart-upload "file://$LAB/complete.json" \
  > "$LAB/receipts/multipart-complete.json"
"${AWS[@]}" head-object --bucket "$BUCKET" --key multipart.bin > "$LAB/receipts/multipart-head.json"

Open multipart sessions consume storage even though list-objects does not show completed objects for them. If a real run stops before completion, record the bucket, key, and upload ID, then use a scoped abort policy. Voxfor’s guide to aborting incomplete S3 uploads safely shows why a bucket-wide guess is weaker than targeting one exact session.

Recalculate What the Multipart ETag Proves

For this unencrypted S3-compatible path, calculate the binary MD5 of each part, concatenate those 16-byte digests, hash that byte string once more, and append -2. Concatenating the printable 32-character hex strings would produce the wrong value.

set -euo pipefail
LAB=/tmp/voxfor-s3-etag-158
grep -qx 'voxfor-s3-etag-lab-v1' "$LAB/.voxfor-s3-etag-lab"
SOURCE_MD5=$(md5sum "$LAB/source.bin" | awk '{print $1}')
MULTIPART_ETAG=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["ETag"].strip("\\\""))' "$LAB/receipts/multipart-head.json")
EXPECTED_MULTIPART=$(python3 - "$LAB/part-1.bin" "$LAB/part-2.bin" <<'PY'
import hashlib,sys
parts=[hashlib.md5(open(path,'rb').read()).digest() for path in sys.argv[1:]]
print(f'{hashlib.md5(b"".join(parts)).hexdigest()}-{len(parts)}')
PY
)
[[ "$MULTIPART_ETAG" == "$EXPECTED_MULTIPART" ]]
[[ "$MULTIPART_ETAG" != "$SOURCE_MD5" ]]
printf 'multipart_etag=%s recalculated=%s multipart_matches_full_md5=no\n' \
  "$MULTIPART_ETAG" "$EXPECTED_MULTIPART" | tee "$LAB/multipart.receipt"

-2 communicates part count, not corruption. Changing only the part boundary can change the ETag while leaving every object byte unchanged; Kurokatta’s same-content experiment demonstrates that effect across one, two, four, and eight parts. Limagito’s multipart verification notes also show why clients need the original part size to recalculate this legacy form reliably.

Verify the Complete Downloads With SHA-256

Now ask the question the ETag cannot answer universally: did each retrieved object reproduce the expected complete byte stream? Download both keys, calculate SHA-256 locally, and compare the stored expected-value metadata as a receipt field. Metadata alone is not proof because it can be copied incorrectly; the downloaded hash is the decisive value in this lab.

set -euo pipefail
LAB=/tmp/voxfor-s3-etag-158
MINIO_PORT=19020
BUCKET=voxfor-etag-evidence
grep -qx 'voxfor-s3-etag-lab-v1' "$LAB/.voxfor-s3-etag-lab"
export AWS_ACCESS_KEY_ID=voxforlab AWS_SECRET_ACCESS_KEY='VoxforLabOnly-158!'
export AWS_DEFAULT_REGION=us-east-1 AWS_EC2_METADATA_DISABLED=true
AWS=("$LAB/venv/bin/aws" --endpoint-url "http://127.0.0.1:$MINIO_PORT" s3api)
SOURCE_SHA=$(sha256sum "$LAB/source.bin" | awk '{print $1}')
"${AWS[@]}" get-object --bucket "$BUCKET" --key single.bin "$LAB/single-download.bin" >/dev/null
"${AWS[@]}" get-object --bucket "$BUCKET" --key multipart.bin "$LAB/multipart-download.bin" >/dev/null
SINGLE_SHA=$(sha256sum "$LAB/single-download.bin" | awk '{print $1}')
MULTIPART_SHA=$(sha256sum "$LAB/multipart-download.bin" | awk '{print $1}')
SINGLE_META=$("${AWS[@]}" head-object --bucket "$BUCKET" --key single.bin --query 'Metadata."full-sha256"' --output text)
MULTIPART_META=$("${AWS[@]}" head-object --bucket "$BUCKET" --key multipart.bin --query 'Metadata."full-sha256"' --output text)
[[ "$SOURCE_SHA" == "$SINGLE_SHA" && "$SOURCE_SHA" == "$MULTIPART_SHA" ]]
[[ "$SOURCE_SHA" == "$SINGLE_META" && "$SOURCE_SHA" == "$MULTIPART_META" ]]
printf 'single_download_sha256=%s multipart_download_sha256=%s metadata_sha256=%s\n' \
  "$SINGLE_SHA" "$MULTIPART_SHA" "$MULTIPART_META" | tee "$LAB/download.receipt"

On Amazon S3, prefer the service’s supported checksum API when your client and workflow expose it. The current AWS multipart checksum tutorial demonstrates per-part checksums and completion. Always retain ChecksumType: a composite SHA-256 is derived from part hashes and is not the same value as a full-file SHA-256. For a distributor hash, deduplication key, or content-addressed artifact, calculate the full byte-stream digest explicitly.

Make One Changed Byte Fail

A positive equality test is incomplete until a changed object is rejected. Corrupt one byte in a local copy, leave the original and stored objects untouched, and prove both byte comparison and full SHA-256 fail.

set -euo pipefail
LAB=/tmp/voxfor-s3-etag-158
grep -qx 'voxfor-s3-etag-lab-v1' "$LAB/.voxfor-s3-etag-lab"
SOURCE_SHA=$(sha256sum "$LAB/source.bin" | awk '{print $1}')
cp "$LAB/multipart-download.bin" "$LAB/corrupt.bin"
printf 'X' | dd of="$LAB/corrupt.bin" bs=1 seek=1048576 conv=notrunc status=none
CORRUPT_SHA=$(sha256sum "$LAB/corrupt.bin" | awk '{print $1}')
[[ "$CORRUPT_SHA" != "$SOURCE_SHA" ]]
! cmp -s "$LAB/corrupt.bin" "$LAB/source.bin"
printf 'corrupt_sha256=%s negative_control=rejected\n' "$CORRUPT_SHA" | tee "$LAB/negative.receipt"

Hash equality is still not application acceptance. A backup may reproduce every byte and still be incomplete, encrypted with a missing key, or unusable by the target version. Voxfor’s Restic restore verification workflow carries the same distinction into recovery, while MinIO Object Lock backup design covers retention and deletion resistance. Integrity, completeness, immutability, and recoverability are separate controls.

Emit the Receipt and Remove Only Owned State

Join the claims before cleanup: the single ETag equals the full MD5 in this narrow control, the multipart ETag equals the independently recalculated composite, both retrieved full SHA-256 values equal the source, the changed-byte control differs, and no upload session remains open.

set -euo pipefail
LAB=/tmp/voxfor-s3-etag-158
MINIO_PORT=19020
BUCKET=voxfor-etag-evidence
grep -qx 'voxfor-s3-etag-lab-v1' "$LAB/.voxfor-s3-etag-lab"
export AWS_ACCESS_KEY_ID=voxforlab AWS_SECRET_ACCESS_KEY='VoxforLabOnly-158!'
export AWS_DEFAULT_REGION=us-east-1 AWS_EC2_METADATA_DISABLED=true
AWS=("$LAB/venv/bin/aws" --endpoint-url "http://127.0.0.1:$MINIO_PORT" s3api)
SOURCE_MD5=$(md5sum "$LAB/source.bin" | awk '{print $1}')
SOURCE_SHA=$(sha256sum "$LAB/source.bin" | awk '{print $1}')
SINGLE_ETAG=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["ETag"].strip("\\\""))' "$LAB/receipts/single-head.json")
MULTIPART_ETAG=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["ETag"].strip("\\\""))' "$LAB/receipts/multipart-head.json")
SINGLE_SHA=$(sha256sum "$LAB/single-download.bin" | awk '{print $1}')
MULTIPART_SHA=$(sha256sum "$LAB/multipart-download.bin" | awk '{print $1}')
CORRUPT_SHA=$(sha256sum "$LAB/corrupt.bin" | awk '{print $1}')
[[ "$SOURCE_MD5" == "$SINGLE_ETAG" && "$SOURCE_MD5" != "$MULTIPART_ETAG" ]]
[[ "$SOURCE_SHA" == "$SINGLE_SHA" && "$SOURCE_SHA" == "$MULTIPART_SHA" ]]
[[ "$SOURCE_SHA" != "$CORRUPT_SHA" ]]
[[ "$("${AWS[@]}" list-multipart-uploads --bucket "$BUCKET" --query 'length(Uploads || `[]`)' --output text)" == 0 ]]
printf 'receipt=pass identical_bytes=yes etag_depends_on_upload_shape=yes full_sha256_detects_corruption=yes\n'

MINIO_PID=$(<"$LAB/minio.pid")
[[ "$(readlink -f "/proc/$MINIO_PID/exe")" == "$LAB/bin/minio" ]]
[[ "$(awk '{print $22}' "/proc/$MINIO_PID/stat")" == "$(<"$LAB/minio.start")" ]]
"$LAB/bin/mc" rb --force "lab/$BUCKET" >/dev/null
kill "$MINIO_PID"
for attempt in $(seq 1 100); do kill -0 "$MINIO_PID" 2>/dev/null || break; sleep 0.05; done
! kill -0 "$MINIO_PID" 2>/dev/null
rm -rf --one-file-system "$LAB"
[[ ! -e "$LAB" ]]
printf 'cleanup=complete\n'

On Debian 13, the reproduced run returned this representative receipt:

source_bytes=12582912
source_md5=7a12f90bb8b90f3ff37573bb6e1cd587
source_sha256=3b04b4305c1213d8136937a483fe4210bd5fe73cd41fd3ca3e6c2397894bd142
single_etag=7a12f90bb8b90f3ff37573bb6e1cd587
single_matches_full_md5=yes
multipart_etag=0d26c270e7bf32abbb3f7bc95d7b5600-2
recalculated_multipart_etag=0d26c270e7bf32abbb3f7bc95d7b5600-2
multipart_matches_full_md5=no
single_download_sha256=3b04b4305c1213d8136937a483fe4210bd5fe73cd41fd3ca3e6c2397894bd142
multipart_download_sha256=3b04b4305c1213d8136937a483fe4210bd5fe73cd41fd3ca3e6c2397894bd142
corrupt_sha256=4ee060dbed039c0c477de2864e3280e12513dbda8ffda6fc3765d329ed7f80f2
negative_control=rejected
receipt=pass identical_bytes=yes etag_depends_on_upload_shape=yes full_sha256_detects_corruption=yes
cleanup=complete

Verification succeeds when the single-part control equals the complete-file MD5 only under the lab’s stated conditions, the multipart ETag equals the independently computed digest-of-part-digests with a -2 suffix, both downloaded SHA-256 values equal the source and stored expected value, the changed-byte control is rejected, no multipart session remains, and the loopback service is absent after cleanup. The reproduced run met every condition with MinIO RELEASE.2025-09-07T16-13-09Z, AWS CLI 1.46.0, and Python 3.13.5.

The lab rollback deletes only bucket voxfor-etag-evidence, stops the PID only after its executable and Linux start time match the marker-owned MinIO process, and removes exactly /tmp/voxfor-s3-etag-158. On real storage, do not delete or overwrite either object merely because ETags differ; preserve version IDs and expected full-object hashes, quarantine the disputed download, and repeat a checksum-enabled read before changing lifecycle or retention policy. If an unattended synchronization plan includes deletion, add a preview and recovery boundary such as Voxfor’s rsync deletion admission workflow.

S3 Multipart ETag Questions

Is an S3 ETag always the MD5 of the file?

No. A qualifying single-part upload may return the content MD5, but multipart uploads, part copies, SSE-KMS, SSE-C, and implementation details create exceptions. Treat ETag as opaque unless the exact upload and encryption contract proves otherwise.

What does the -2 suffix mean?

For the common unencrypted multipart ETag form, -2 records that two parts contributed to the composite. It does not mean the object is damaged, duplicated, or version 2.

Can two identical objects have different ETags?

Yes. Uploading identical bytes with different part sizes or part counts can produce different multipart ETags. A single copy operation can also change checksum metadata even when content stays the same.

Can I compare a multipart ETag with a local MD5?

Not with the complete-file MD5. You can sometimes reproduce the legacy multipart ETag when you know the exact part boundaries and encryption conditions, but a full SHA-256 is clearer for content identity.

Should SHA-256 stored in object metadata be trusted by itself?

No. Metadata is an expected value, not execution evidence. Compare it with a freshly calculated hash of the complete downloaded bytes, and protect the metadata source from unauthorized changes.

What should an integrity receipt retain?

Keep the bucket, key, version ID when enabled, byte length, upload method, encryption mode, ETag, checksum algorithm and type, expected full-object hash, client version, timestamp, and the result of a checksum-validated download. Keep credentials and customer data out of the receipt.

Leave a Reply

Your email address will not be published. Required fields are marked *