S3 Check if Object Exists: HeadObject in Python (boto3), JavaScript, Java, C#, Go and the CLI, Why You Get 403 Instead of 404, and Upload Only If Missing

Logeshwaran
—

To check if an object exists in S3, send a HeadObject request for its exact key: it returns the object's size and metadata without downloading it, or an HTTP 404 if the key is not there. In Python, call s3.head_object(Bucket=..., Key=...) and treat a ClientError whose code is "404" as "does not exist"; in JavaScript (SDK v3), send HeadObjectCommand and catch NotFound; from the command line, run aws s3api head-object and check the exit code. Two traps catch almost everyone. First, head_object never raises NoSuchKey, so code that catches NoSuchKey crashes on every missing object. Second, if your role lacks the s3:ListBucket permission, S3 answers a missing object with 403 Forbidden, not 404, and a script that treats 403 as "missing" will happily overwrite or skip files it was never allowed to see.

Jake's photo uploader was supposed to skip product photos that were already in the bucket. It worked for a year. Then Ethan tightened the uploader's IAM role to "only what it needs", and the next morning every upload failed with An error occurred (403) when calling the HeadObject operation: Forbidden. Nothing was wrong with the photos. The role could still read objects; it just could no longer be told which ones did not exist.

⚡ Quick Answer

• The call: HeadObject on the exact key. 200 = exists, 404 = does not. Python · JavaScript · Java · C# · Go · AWS CLI.

• 403 instead of 404? Add s3:ListBucket on the bucket. Why S3 hides "not found".

• Not NoSuchKey: a HEAD response has no body, so the error code is just 404. Catch the right error.

• About to upload? Skip the check and use a conditional write (If-None-Match: *). Create only if absent.

Does the bucket exist? · Folders and prefixes · Thousands of keys · Versioned buckets · Errors · FAQ.

This guide shows the reliable way to check whether an S3 object or file exists in Python (boto3), JavaScript (AWS SDK v3), Java, C#, Go and the AWS CLI, with every example tested and its real output, then the cases that make simple checks wrong: 403 versus 404, folders, case sensitivity, versioned buckets, checking thousands of keys, and the race between "check" and "upload". If S3 keys and buckets are new to you, what Amazon S3 is covers them first.

🧭 NEW HERE? READ THESE FIRST

New to S3 or boto3? These pages explain the basics this guide uses:

💡 S3 has no "exists" call. It has a "tell me about this object" call, and a missing object is its error.

How to check if an object exists in S3: HeadObject

S3 has no dedicated "does it exist?" API. The standard answer is HeadObject, the HTTP HEAD version of a download: it returns the same headers a GET would (size, content type, ETag, last modified, metadata) with no body. If the key exists and you may read it, you get 200 and the metadata. If it does not, you get an error. There are other ways, and they all have a cost or a catch:

MethodGood forCatch
HeadObject on the keyOne exact key; returns size and metadata too403 instead of 404 without s3:ListBucket
ListObjectsV2 with the key as prefixFolders (prefixes); many keys under one prefixA prefix also matches longer keys; must compare the key exactly
GetObjectWhen you need the content anywayDownloads the object; wasteful just to check
aws s3 ls s3://bucket/keyA quick look by handPrefix match: reports success for keys that do not exist
Conditional PutObject (If-None-Match: *)"Upload only if it is not already there"Not a check: it writes when absent, fails with 412 when present

Two facts apply to every method. Keys are exact and case-sensitive: Products/kettle-blue.jpg and products/kettle-blue.jpg are different objects, and a check for the wrong case returns 404 even though "the file is right there". And a check is only true at the moment you make it; another process can create or delete the object a millisecond later.

Python (boto3): check if a key exists

With the boto3 client, call head_object and look at the error code when it fails:

import boto3
from botocore.exceptions import ClientError

s3 = boto3.client("s3")

def s3_object_exists(bucket: str, key: str) -> bool:
    try:
        s3.head_object(Bucket=bucket, Key=key)
        return True
    except ClientError as e:
        code = e.response["Error"]["Code"]
        if code in ("404", "NoSuchKey", "NotFound"):
            return False
        raise   # 403, throttling, network: do not pretend the object is missing

print(s3_object_exists("jakes-shop-photos", "products/kettle-blue.jpg"))   # True
print(s3_object_exists("jakes-shop-photos", "products/kettle-red.jpg"))    # False

Here is what head_object actually returns in each case, run with boto3 1.43.118 against an S3-compatible test endpoint:

existing key:   OK  ContentLength=2048, ContentType='image/jpeg', ETag='"cfb767f2..."'
missing key:    ClientError code='404' message='Not Found' http=404
wrong case:     ClientError code='404' message='Not Found' http=404
missing bucket: ClientError code='404' message='Not Found' http=404
get_object on the missing key: ClientError code='NoSuchKey' message='The specified key does not exist.' http=404

Three details make the function above correct:

  • It checks the error code, not the exception class. A HEAD response has no body, so S3 cannot send its usual XML error with NoSuchKey in it; botocore can only report the status, and the code becomes the string "404".
  • It re-raises everything else. Returning False for any error is the classic bug: a 403 from a permissions change, an expired credential or a timeout then looks like "file missing", and the script re-uploads, overwrites or deletes things it should not.
  • A missing bucket also returns "404" here. If you need to tell "no such bucket" from "no such key", check the bucket once with head_bucket before checking many keys.

If you use the boto3 resource interface, Object.load() does the same HEAD, and fails the same way:

obj = boto3.resource("s3").Object("jakes-shop-photos", "products/kettle-red.jpg")
try:
    obj.load()          # HeadObject under the hood
    print(obj.content_length, obj.last_modified)
except ClientError as e:
    if e.response["Error"]["Code"] == "404":
        print("not found")
    else:
        raise

A successful head_object is useful in itself: it returns ContentLength, ContentType, ETag, LastModified and your custom metadata, so "does it exist" and "is it the same file" can be one call. Comparing ContentLength and ETag with the local file is how Jake's uploader decides whether a photo changed. boto3 client vs resource explains when to use which interface.

Why head_object never raises NoSuchKey

This is the bug in most copied snippets:

try:
    s3.head_object(Bucket=bucket, Key=key)
except s3.exceptions.NoSuchKey:      # never matches for head_object
    print("missing")

Run against a missing key, that except clause is skipped and the program stops with an unhandled ClientError:

NOT NoSuchKey -> ClientError code 404

The reason is in how HEAD works. Every S3 error normally arrives as a small XML document that names the error, such as <Code>NoSuchKey</Code>. A HEAD response is not allowed to have a body, so S3 can only send a bare status: 400, 403, 404, 405, 412 or 304. The SDK therefore has nothing more specific than the number. get_object on the same missing key does return NoSuchKey, because a GET response carries the XML. So: catch NoSuchKey for downloads, check for "404" for HEAD. The NoSuchKey guide covers every reason a key that "should" exist is not found.

JavaScript (AWS SDK v3): HeadObjectCommand

In JavaScript, the v3 SDK gives the HEAD error a class of its own, NotFound, exported from @aws-sdk/client-s3:

import { S3Client, HeadObjectCommand, NotFound } from "@aws-sdk/client-s3";

const s3 = new S3Client({});

async function exists(Bucket, Key) {
  try {
    await s3.send(new HeadObjectCommand({ Bucket, Key }));
    return true;
  } catch (err) {
    if (err instanceof NotFound || err.$metadata?.httpStatusCode === 404) return false;
    throw err; // 403, network errors and the rest: do not pretend the object is missing
  }
}

console.log("kettle-blue:", await exists("jakes-shop-photos", "products/kettle-blue.jpg"));
console.log("kettle-red:", await exists("jakes-shop-photos", "products/kettle-red.jpg"));

Run with @aws-sdk/client-s3 3.1156.0 on Node.js 24, with the error inspected as well:

kettle-blue: true
kettle-red: false
error name: NotFound | status: 404 | instanceof NotFound: true | instanceof NoSuchKey: false | instanceof S3ServiceException: true

Same lesson as in Python: NoSuchKey does not match a HEAD error, and the safe test is the class NotFound or the status code 404. Checking err.name === "NotFound" also works and survives bundlers that duplicate the SDK classes. The older v2 SDK (aws-sdk) reported the same case as err.code === "NotFound", with statusCode 404; it has been out of support since September 2025, so new code should use v3.

Java (AWS SDK 2.x): headObject and NoSuchKeyException

Java is the exception to the "no NoSuchKey on HEAD" rule. The AWS SDK for Java 2.x turns a 404 from headObject into a NoSuchKeyException for you, so the natural code works:

import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.model.*;

static boolean exists(S3Client s3, String bucket, String key) {
    try {
        s3.headObject(HeadObjectRequest.builder().bucket(bucket).key(key).build());
        return true;
    } catch (NoSuchKeyException e) {
        return false;   // any other S3Exception (403, 400, ...) propagates
    }
}

Run with SDK 2.57.3:

kettle-blue: true
kettle-red: false
exception class: NoSuchKeyException | statusCode: 404 | errorCode: NoSuchKey
headBucket missing bucket: NoSuchBucketException | statusCode: 404

So the same 404 is NoSuchKeyException in Java, NotFound in JavaScript and Go, and a plain "404" code in Python. If you port existence checks between languages, port the error handling deliberately, not by copying the exception name. A 403 arrives as a generic S3Exception with statusCode() 403, which should propagate. In the old SDK 1.x, s3.doesObjectExist(bucket, key) did the HEAD for you; SDK 1.x reached end of support at the end of 2025, and 2.x has no such helper.

C# and .NET: GetObjectMetadataAsync

In the AWS SDK for .NET, the HEAD call is GetObjectMetadataAsync. A missing object throws AmazonS3Exception with StatusCode set to HttpStatusCode.NotFound; filter on that and let everything else through:

using System.Net;
using Amazon.S3;

async Task<bool> ExistsAsync(IAmazonS3 s3, string bucket, string key)
{
    try
    {
        await s3.GetObjectMetadataAsync(bucket, key);
        return true;
    }
    catch (AmazonS3Exception e) when (e.StatusCode == HttpStatusCode.NotFound)
    {
        return false;
    }
}

Run with the AWSSDK.S3 4.0.104.6 package on .NET 9:

kettle-blue: True
kettle-red: False
exception: AmazonS3Exception | StatusCode: NotFound | ErrorCode: 'NotFound' | Message: 'Error making request with Error Code NotFound
  and Http Status Code NotFound. No further error information was returned by the service.'
size 2048, type image/jpeg, etag "cfb767f225d58469c5de3632a8803958"

That message, "No further error information was returned by the service", is the HEAD-has-no-body rule in .NET's words. If you upload with TransferUtility, it has no exists method of its own; call GetObjectMetadataAsync on the same client first, or better, make the upload conditional (see below). Use the when filter rather than catching every AmazonS3Exception, for the same reason as in the other languages: a 403 is not a "no".

Go (AWS SDK v2): HeadObject and types.NotFound

In Go, check the error with errors.As against *types.NotFound:

import (
	"context"
	"errors"

	"github.com/aws/aws-sdk-go-v2/aws"
	"github.com/aws/aws-sdk-go-v2/service/s3"
	"github.com/aws/aws-sdk-go-v2/service/s3/types"
)

func exists(ctx context.Context, client *s3.Client, bucket, key string) (bool, error) {
	_, err := client.HeadObject(ctx, &s3.HeadObjectInput{Bucket: aws.String(bucket), Key: aws.String(key)})
	if err == nil {
		return true, nil
	}
	var nf *types.NotFound
	if errors.As(err, &nf) {
		return false, nil
	}
	return false, err // 403 and the rest: report, do not guess
}

Run with Go 1.25 and service/s3 v1.114.5:

products/kettle-blue.jpg true <nil>
products/kettle-red.jpg false <nil>
errors.As NoSuchKey: false | ErrorCode(): NotFound

As in JavaScript, *types.NoSuchKey does not match a HEAD error. Returning the error as the second value, instead of folding it into false, keeps a permissions problem visible to the caller.

AWS CLI: check if a file exists in an S3 bucket

From a shell, use aws s3api head-object and the exit code. Tested with AWS CLI 1.46.2:

$ aws s3api head-object --bucket jakes-shop-photos --key products/kettle-blue.jpg
{
    "AcceptRanges": "bytes",
    "LastModified": "Fri, 23 Oct 2026 06:48:44 GMT",
    "ContentLength": 2048,
    "ETag": "\"cfb767f225d58469c5de3632a8803958\"",
    "ContentType": "image/jpeg",
    "Metadata": {}
}
exit code: 0

$ aws s3api head-object --bucket jakes-shop-photos --key products/kettle-red.jpg

An error occurred (404) when calling the HeadObject operation: Not Found
exit code: 255

A missing object is a service error, so the exit code is not zero: 255 in AWS CLI version 1, and 254 ("the service returned an error") in version 2. A permissions problem produces the same exit code with (403) ... Forbidden, so in scripts that must tell the two apart, capture the message as well. A simple check in Bash:

if aws s3api head-object --bucket jakes-shop-photos --key products/kettle-red.jpg >/dev/null 2>&1; then
  echo "exists"
else
  echo "not found (or no access)"
fi

And the one to avoid: aws s3 ls with a key looks like a check, but it matches by prefix. Listing a key that does not exist, products/kettle, succeeded and printed a different object:

$ aws s3 ls s3://jakes-shop-photos/products/kettle-red.jpg
exit code: 1

$ aws s3 ls s3://jakes-shop-photos/products/kettle
2026-10-23 06:48:44       2048 kettle-blue.jpg
exit code: 0

So aws s3 ls returns 0 for any key that is the beginning of another key: report "exists" when only report-2026.pdf does. Use head-object for an exact answer. To wait until an object appears (for example, the output of a job), the CLI has a built-in waiter that polls HeadObject every 5 seconds, up to 20 times:

aws s3api wait object-exists --bucket jakes-shop-photos --key reports/october.csv
aws s3api wait object-not-exists --bucket jakes-shop-photos --key tmp/lock

boto3 has the same waiters: s3.get_waiter("object_exists").wait(Bucket=..., Key=...), with a 5-second delay and 20 attempts by default, so it gives up after about 100 seconds with a WaiterError.

Check if an S3 bucket exists

For buckets, the call is HeadBucket. Bucket names are global, so the answer has three useful outcomes, not two:

  1. 200: the bucket exists and you may use it. The response includes the header x-amz-bucket-region.
  2. 404: no bucket with that name exists anywhere. The name may be free (though another account can take it at any moment).
  3. 403: the bucket exists, but it belongs to someone else or you lack s3:ListBucket on it. You cannot create a bucket with that name.
for b in ("jakes-shop-photos", "jakes-shop-nope"):
    try:
        r = s3.head_bucket(Bucket=b)
        print(b, "-> exists in", r["BucketRegion"])
    except ClientError as e:
        print(b, "->", e.response["Error"]["Code"], e.response["Error"]["Message"])
jakes-shop-photos -> exists in us-east-1
jakes-shop-nope -> 404 Not Found

S3 returns x-amz-bucket-region for any bucket name, even one you cannot access, which is how the SDKs find the right Region by themselves. From the CLI: aws s3api head-bucket --bucket NAME, and aws s3api wait bucket-exists to wait for a new bucket. HeadBucket requires s3:ListBucket, and like every HEAD it has no error body, so codes are bare numbers in Python.

403 Forbidden instead of 404: the s3:ListBucket rule

This is what broke Jake's uploader. When the object you ask about does not exist, the answer depends on whether you are allowed to list the bucket:

  • With s3:ListBucket on the bucket: 404 Not Found.
  • Without it: 403 Forbidden, even though s3:GetObject is allowed.

It is deliberate. Without permission to list the bucket, you are not supposed to learn which keys exist, and a 404 would tell you exactly that ("this name is free"). So S3 returns the same 403 for "missing" as for "not allowed", and you cannot tell them apart. HeadObject needs s3:GetObject to work at all; the existence check needs both. A minimal policy for an uploader that checks before it writes:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "ReadAndWriteObjects",
      "Effect": "Allow",
      "Action": ["s3:GetObject", "s3:PutObject"],
      "Resource": "arn:aws:s3:::jakes-shop-photos/products/*"
    },
    {
      "Sid": "ListSoMissingIs404",
      "Effect": "Allow",
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::jakes-shop-photos",
      "Condition": { "StringLike": { "s3:prefix": "products/*" } }
    }
  ]
}

Note the two different resources: object actions use the bucket ARN with /*, while s3:ListBucket applies to the bucket ARN itself. Putting ListBucket on arn:aws:s3:::jakes-shop-photos/* is the most common mistake, and it silently does nothing. The s3:prefix condition limits listing to the folder the uploader works in. S3 Access Denied walks through the other causes of 403, such as KMS keys, bucket policies and Block Public Access.

Ethan's first "least privilege" role had removed ListBucket because the uploader "never lists anything". It does, implicitly, every time it asks whether something exists. "Least privilege still has to include the privileges you use," Jake said, and Ethan has quoted that back to him ever since.

Upload only if it does not exist: conditional writes

"Check, then upload" has a gap: between the HEAD that says "missing" and the PUT, another process can upload the same key, and one of the two uploads silently overwrites the other. Since 2024, S3 can close that gap for you with a conditional write. Add If-None-Match: * to the upload, and S3 writes the object only if no object with that key exists:

# Python
s3.put_object(Bucket="jakes-shop-photos", Key="orders/o-1001.json", Body=b"{}", IfNoneMatch="*")

// JavaScript (SDK v3)
await s3.send(new PutObjectCommand({ Bucket: "jakes-shop-photos", Key: "orders/o-1001.json", Body: "{}", IfNoneMatch: "*" }));
put new key with IfNoneMatch: OK
put same key again with IfNoneMatch: ClientError code='PreconditionFailed'
  message='At least one of the pre-conditions you specified did not hold' http=412
conditional put on existing key (JavaScript): PreconditionFailed 412

The rules: the first conditional write to finish wins, and later ones get 412 Precondition Failed. A 409 Conflict can come back if a delete of the same key succeeds while your conditional write is in progress; retrying the upload is the documented response. In a versioned bucket, a key whose current version is a delete marker counts as absent, so the write succeeds. And If-Match with an ETag does the opposite job: "overwrite only if the object is still the version I read", which is how you avoid lost updates on files that several processes edit.

So when the reason for checking is "do not overwrite", skip the check and make the upload itself conditional. One request instead of two, and no race. boto3 upload_file vs put_object covers the upload calls themselves.

Check if a folder (prefix) exists

S3 has no real folders. "Folders" are key prefixes, sometimes with an empty placeholder object whose key ends in a slash (the console creates one when you click Create folder). That gives two kinds of "folder", and a HEAD only finds one of them:

head 'products/'  (zero-byte folder object): OK  ContentLength=0
head 'products'   (no slash):                ClientError code='404' message='Not Found'
list prefix products/ KeyCount 1
list prefix orders/   KeyCount 0

The reliable test for "is there anything under this prefix?" is a listing that asks for one key:

def prefix_exists(bucket: str, prefix: str) -> bool:
    if not prefix.endswith("/"):
        prefix += "/"
    r = s3.list_objects_v2(Bucket=bucket, Prefix=prefix, MaxKeys=1)
    return r["KeyCount"] > 0

Add the trailing slash on purpose: without it, the prefix photos also matches photos-old/ and photos.zip. A listing needs s3:ListBucket, and it is billed as a LIST request. Listing more than 1,000 objects covers listing with pagination when you need every key, not just one.

Checking thousands of keys: HEAD each one, or list once?

Jake's uploader checks about 4,000 photos every night. One HEAD per photo is 4,000 requests. Listing the folder instead returns up to 1,000 keys per request, so 4 requests, and you then check each name against the list in memory:

def existing_keys(bucket: str, prefix: str) -> dict:
    found = {}
    for page in s3.get_paginator("list_objects_v2").paginate(Bucket=bucket, Prefix=prefix):
        for obj in page.get("Contents", []):
            found[obj["Key"]] = (obj["Size"], obj["ETag"])
    return found

already = existing_keys("jakes-shop-photos", "products/")
to_upload = [p for p in local_photos if f"products/{p.name}" not in already]

The prices make the choice. In S3 Standard (US East), HEAD is billed like GET at $0.0004 per 1,000 requests, and LIST like PUT at $0.005 per 1,000. So one LIST costs the same as 12.5 HEADs, but answers for up to 1,000 keys:

Checking 4,000 keys in one prefixRequestsCost per runPer year, nightly
HEAD every key4,000$0.0016$0.58
List the prefix once4$0.00002$0.007

The money is small at this size, but the time is not: 4 requests finish in well under a second, 4,000 sequential HEADs take minutes. The rule of thumb: if you are checking more than about a dozen keys under the same prefix, list once. If you check a few scattered keys, HEAD them. And if the bucket is huge but you check only a handful, HEAD is better than listing millions of keys. For whole-bucket inventories, S3 Inventory reports deliver a daily or weekly list without any requests from you. S3 request pricing explains the request classes.

Versioned buckets: deleted, but not gone

In a bucket with versioning on, "delete" adds a delete marker instead of removing data. A check then answers "does a current version exist?", which is usually what you want, but the details matter when you are recovering files:

delete -> {'DeleteMarker': True, 'VersionId': '098d293c-...'}
head after delete: 404 404 x-amz-delete-marker = true
head old version:  200 2 bytes
head the delete marker version: 405 405
versions: 1 delete markers: 1
  • A plain HEAD on a deleted key returns 404, with the response header x-amz-delete-marker: true. That header is how you tell "deleted, recoverable" from "never existed".
  • A HEAD with the old VersionId still returns 200: the data is there.
  • A HEAD aimed at the delete marker's own version ID returns 405 Method Not Allowed.

To restore a "deleted" file, delete the delete marker (by its version ID), and the previous version becomes current again. In boto3, the header is in e.response["ResponseMetadata"]["HTTPHeaders"] on the ClientError.

HeadObject errors and what they mean

ErrorMost likely meaningWhat to do
An error occurred (404) when calling the HeadObject operation: Not FoundThe key (or the bucket) does not exist, and you are allowed to know thatCheck spelling, case, prefix and Region; it may simply be missing
An error occurred (403) when calling the HeadObject operation: ForbiddenMissing s3:GetObject, or the key is missing and you lack s3:ListBucket; also KMS or bucket-policy denialsAdd s3:ListBucket on the bucket ARN; then check the other 403 causes
An error occurred (400) when calling the HeadObject operation: Bad RequestA signing or Region problem, an x-amz-server-side-encryption header sent on the HEAD, or an SSE-C object checked without its key headersUse the bucket's Region, drop encryption headers from the HEAD, send the customer key headers for SSE-C objects
(405) ... Method Not AllowedYou asked for a version ID that is a delete markerList versions and pick a real version
PreconditionFailed (412) on PutObjectA conditional write found an existing objectExpected: the object exists; do not overwrite
Waiter ObjectExists failed: Max attempts exceededThe object did not appear within about 100 secondsIncrease the waiter's attempts, or check the producer
Exception never caught, program crashesCatching NoSuchKey around head_objectCatch ClientError and check for code "404"

Best practices for S3 existence checks

  1. Use HeadObject for one exact key, never aws s3 ls or a prefix listing without an exact comparison.
  2. Grant s3:ListBucket (on the bucket ARN, ideally limited by s3:prefix) to anything that checks for existence.
  3. Treat only 404 as "missing". Re-raise 403 and every other error.
  4. Prefer a conditional write (If-None-Match: *) when the goal is "do not overwrite".
  5. List once for many keys under the same prefix; HEAD a few scattered keys.
  6. Remember keys are case-sensitive and that a check can be out of date a moment later.
  7. Test your checks locally against a mock or local endpoint before you point them at production buckets.

Troubleshooting by symptom

The object is in the console, but my check says it does not exist

Compare the exact key: case, spaces, a leading slash, a missing folder prefix, and the bucket and Region your code uses.

Every check returns 403

The role lacks s3:ListBucket, so missing objects report 403. Add it on the bucket ARN, then retest an object you know exists.

My except NoSuchKey block never runs

HEAD errors have no body, so the code is "404", not NoSuchKey. Catch ClientError and check the code.

aws s3 ls says the file exists, but it does not

s3 ls matches by prefix. Use aws s3api head-object for an exact key.

The check says missing, then the upload fails with 412

Another process created the object in between, and your conditional write correctly refused to overwrite it.

Checking thousands of files is slow

List the prefix once with a paginator and compare in memory instead of one HEAD per file.

A deleted file still shows up in versions

Versioning keeps it behind a delete marker. HEAD returns 404 with x-amz-delete-marker: true; delete the marker to restore it.

Frequently asked questions

How do I check if an object exists in S3?

Send HeadObject for the exact key. 200 means it exists; 404 means it does not, if you have s3:ListBucket.

How do I check if a file exists in S3 with Python?

Call s3.head_object(Bucket, Key) in a try block; return False when the ClientError code is "404", and re-raise any other error.

How do I check if a key exists in S3 with boto3 without downloading it?

Use head_object or the resource's Object.load(). Both send a HEAD request and return metadata only.

Why does head_object not raise NoSuchKey?

HEAD responses have no body, so S3 cannot send the NoSuchKey error document. boto3 reports the bare status as code "404".

Why do I get 403 Forbidden instead of 404 for a missing object?

Without s3:ListBucket on the bucket, S3 answers a missing key with 403, so you cannot learn which keys exist.

How do I check if an S3 object exists in Node.js?

Send HeadObjectCommand from @aws-sdk/client-s3 and catch NotFound (status 404). Re-throw other errors.

How do I check if a file exists in an S3 bucket with the AWS CLI?

Run aws s3api head-object --bucket B --key K. Exit code 0 means it exists; a 404 error (exit 254 in CLI v2, 255 in v1) means it does not.

Can I use aws s3 ls to check if a file exists?

Not reliably. It matches by prefix, so it succeeds for a key that is only the start of another key.

How do I check if a folder exists in S3?

List with Prefix set to the folder name plus a slash and MaxKeys=1. KeyCount above zero means something is in it.

Is HeadObject charged?

Yes, as a GET-class request: $0.0004 per 1,000 in S3 Standard in US East. Listing costs $0.005 per 1,000 but covers up to 1,000 keys.

How do I upload a file only if it does not exist?

Use a conditional write: PutObject with IfNoneMatch="*". S3 returns 412 PreconditionFailed if the key already exists.

Are S3 keys case-sensitive?

Yes. Products/a.jpg and products/a.jpg are different keys, and checking the wrong case returns 404.

How do I wait until an S3 object exists?

Use the object_exists waiter in boto3 or aws s3api wait object-exists. It polls every 5 seconds, 20 times by default.

How do I tell a deleted object from one that never existed?

In a versioned bucket, the 404 for a deleted key includes the header x-amz-delete-marker: true.

What permissions does HeadObject need?

s3:GetObject on the object, plus s3:ListBucket on the bucket if you want a 404 rather than a 403 for missing keys.

How do I check if a file exists in S3 with Java?

Call s3.headObject and catch NoSuchKeyException. In SDK 2.x a HEAD 404 is mapped to that exception; let other S3Exception errors through.

How do I check if a file exists in S3 with C#?

Call GetObjectMetadataAsync and catch AmazonS3Exception when StatusCode is HttpStatusCode.NotFound.

How do I check if an object exists in S3 with Go?

Call HeadObject and use errors.As with *types.NotFound. NoSuchKey does not match a HEAD error.

How do I check if an S3 bucket exists?

Use HeadBucket. 200 means it exists and you can use it, 404 means no such bucket, and 403 means it exists but is not yours or you lack access.

How do I count the number of files in an S3 bucket or folder?

Run aws s3 ls s3://bucket/prefix/ --recursive --summarize, or add up KeyCount across list_objects_v2 pages. For huge buckets, use S3 Inventory or the console's metrics.

How do I check many S3 objects at once?

List the prefix with a paginator, put the keys in a set or dict, and check names in memory instead of one HEAD per key.

Why do I get "An error occurred (404) when calling the HeadObject operation: Not Found" in boto3?

Because no object exists at exactly that key: check case, the full prefix and URL-encoded characters such as spaces. head_object reports a missing object as a ClientError with code '404', so catch it and check e.response['Error']['Code'].

Why does head_object return 403 even with s3:ListBucket permission?

HeadObject itself needs s3:GetObject on the object ARN (arn:aws:s3:::bucket/*). s3:ListBucket on the bucket ARN only changes the answer for a missing key from 403 to 404.

How do I handle AWS CLI exceptions when an object does not exist?

Check the exit code: aws s3api head-object exits non-zero (255 on AWS CLI 1.46.2) and prints (404) Not Found to stderr, so use if aws s3api head-object ...; then in scripts.

For readers preparing for an AWS certification

We have been covering AWS certifications alongside our fixes, so here is a short extra for readers studying for one. If you only came for the fix, you already have it; feel free to skip this part.

Exams that test this topic: Developer – Associate (DVA-C03 from December 1, 2026) • Solutions Architect – Associate (SAA-C03) • CloudOps Engineer – Associate (SOA-C03).

Here are the exam-style questions this topic tends to raise, with the reasoning you need to answer them:

1. A Lambda function checks whether an S3 object exists with HeadObject and receives 403 Forbidden for keys that are known to be missing. The role has s3:GetObject. What is missing?

s3:ListBucket on the bucket ARN; without it, S3 returns 403 instead of 404 for missing objects so callers cannot learn which keys exist.

2. Two workers may upload the same S3 key at the same time and neither may overwrite the other's object. What is the simplest solution?

A conditional write: PutObject with If-None-Match: *; the first upload succeeds and later ones fail with 412 Precondition Failed.

3. A nightly job must check 50,000 keys under one prefix before uploading. Which approach minimizes requests?

List the prefix with ListObjectsV2 (up to 1,000 keys per request, about 50 requests) and compare in memory, instead of 50,000 HeadObject calls.

4. Python code wraps head_object in 'except s3.exceptions.NoSuchKey' but crashes on missing objects. Why?

HEAD responses have no body, so the error is a ClientError with code '404', not NoSuchKey; catch ClientError and check the code.

5. In a versioned bucket, how can an application tell a deleted object from one that never existed?

The 404 for a deleted key includes the response header x-amz-delete-marker: true; earlier versions remain readable by VersionId.

6. Which HeadBucket result shows that a bucket name is already taken by another account?

403 Forbidden: the bucket exists but you have no access; 404 means no bucket with that name exists.

Exam codes and dates change often. Our AWS certification changes page keeps track of what is current.

Jake's uploader now lists the folder once a night, uploads what is missing with a conditional write, and logs a 412 as "already there, skipped". It has not failed since. Jake still does not know what s3:ListBucket does, but he knows it is the reason he gets his morning back.

📌 If you keep one line from this page

Check with HeadObject and treat only a 404 as "missing": catch ClientError code "404" (not NoSuchKey), grant s3:ListBucket so missing objects are not reported as 403, and use If-None-Match: * when you only want to avoid overwriting.

Examples run with boto3 1.43.118, AWS SDK for JavaScript 3.1156.0 on Node.js 24, AWS SDK for Java 2.57.3, AWSSDK.S3 4.0.104.6 on .NET 9, AWS SDK for Go v2 (s3 v1.114.5) and AWS CLI 1.46.2 against an S3-compatible test endpoint, October 2026.

Revision note. Written October 23, 2026. "Look before you leap," the proverb says; with S3, the better advice is to leap with a condition attached.

Related