S3 MalformedXML "The XML You Provided Was Not Well-Formed": Every Cause, From Empty Delete Lists to Uploads Sent to the Bucket

Logeshwaran
—

S3 MalformedXML, the error that says "The XML you provided was not well-formed or did not validate against our published schema", almost never means your XML is broken. You probably never wrote any XML at all. Your SDK or the AWS CLI turned your request into perfectly tidy XML, and S3 rejected it because the request breaks one of S3's rules: a delete with an empty list of keys or more than 1,000, an upload sent to the bucket instead of a file, a multipart upload with no parts, a lifecycle rule with no Filter, versioning set to "Disabled", or a Glacier restore with Tier in the wrong place. Here is the surprise that explains why the error feels so random: current versions of boto3, the AWS CLI and the JavaScript SDK check types and required fields, but not these rules. An empty delete list, a 1,001-key list and a rule without a Filter all leave your machine without a murmur, and S3 is the first to say no. Find the operation name in the error, match it to the section below, and the fix is usually one line.

Ethan wrote a small nightly job for Jake's repair shop: it lists the photos of finished repairs that are older than 90 days and deletes them from S3 in one call. It ran for six weeks without a single complaint. Then one quiet Monday it failed with MalformedXML, and Jake, reading the alert on his phone, asked the obvious question: "How can our XML be malformed? We never wrote any XML." He was right, and that is exactly the point. The job failed on the one night it had nothing to delete. This page explains why that breaks, how to find which of a dozen causes you have hit, the fix for each one in Python, JavaScript and the CLI, and how to see the exact XML your code sends, so the error never feels random again.

⚡ Quick Answer

• On DeleteObjects? → Your list was empty or held more than 1,000 keys. Skip empty lists and send batches of 1,000. Batch helpers.

• On an upload from a browser or app? → The URL has no file name, so S3 read it as "create a bucket". Put the key in the URL. The fix.

• On CompleteMultipartUpload, or an empty file? → Zero parts. Upload empty files with a plain PutObject. Details.

• On PutBucketLifecycleConfiguration? → Add a Filter (empty means the whole bucket), and use And for two conditions. Working rules.

• On PutBucketVersioning? → There is no "Disabled". Use Suspended. Versioning and MFA Delete.

• On RestoreObject? → Tier goes inside GlacierJobParameters. The right shape.

Still stuck? Print the exact XML your code sends and compare it with the rules for that operation.

New to S3? Our plain-English guide to what Amazon S3 is explains buckets, keys and requests from zero.

What MalformedXML really means, when you never wrote any XML

Most of S3's settings travel as small XML documents. When you call delete_objects in Python, DeleteObjectsCommand in JavaScript or aws s3api put-bucket-lifecycle-configuration at the command line, the SDK takes your dictionary or JSON, turns it into XML, signs it and sends it. S3 then checks that document against its schema, the formal description of which elements may appear, in what order, how many times and with which values.

The full error has the same three parts everywhere:

  • The error code, MalformedXML.
  • The message, "The XML you provided was not well-formed or did not validate against our published schema."
  • The HTTP status, 400 Bad Request.

Python prints it as botocore.exceptions.ClientError: An error occurred (MalformedXML) when calling the DeleteObjects operation: The XML you provided was not well-formed or did not validate against our published schema. The AWS CLI prints the same sentence. Java shows Status Code: 400; Error Code: MalformedXML inside an AmazonS3Exception.

Read the message again and notice the word "or". Your XML can fail in two ways: it can be broken XML, with a missing closing tag or a stray character, or it can be perfectly formed XML that breaks a rule. When an SDK writes the XML for you, the first kind practically never happens. It is the second kind every time.

Jake: "So it's like a delivery form. The courier's machine prints it neatly, but if I tick 'no boxes' in the quantity field, the depot still sends it back."

Ethan: "That's it exactly. The printer checks that the form is filled in. Only the depot checks whether it makes sense. And with S3, the SDK is the printer."

That is the shock worth remembering. Current versions of boto3, the AWS CLI v2 and the JavaScript SDK v3 all check that you passed the right types and the required fields, then send everything else to S3 untouched. Every one of these requests leaves your machine and only fails at S3: a delete with no keys, a delete with 1,001 keys, a lifecycle rule without a Filter, a rule with both Days and ExpiredObjectDeleteMarker, versioning set to Disabled, Object Lock set to Disabled, a restore with Tier at the top level, a multipart completion with no parts, and a replication rule without its required parts. So "it passed in my editor" and "the SDK accepted it" tell you nothing. The only judge is S3.

Find your cause from the operation name

The single most useful word in the error is the operation name, in the part that says when calling the ... operation. Each operation has its own rules, and each has one or two favorite mistakes:

Operation in the errorMost likely causeFix
DeleteObjectsAn empty list of keys, or more than 1,000 keys in one requestSkip empty lists, batch by 1,000
CreateBucket, or an upload that fails with this errorA PUT sent to the bucket address with no object keyPut the key in the URL
CompleteMultipartUploadNo parts in the list, often from a zero-byte filePutObject for empty files
PutBucketLifecycleConfigurationNo Filter, two conditions without And, or Days with ExpiredObjectDeleteMarkerWorking rules
PutBucketVersioningStatus set to DisabledUse Suspended
PutObjectLockConfigurationTrying to switch Object Lock off, or Days and Years togetherWhat you can change
RestoreObjectTier at the top level of the requestTier inside GlacierJobParameters
PutBucketReplicationA Filter without Priority or DeleteMarkerReplicationThe full rule
Any of these, from your own HTTP codeHand-built XML: a special character in a key, wrong element order, missing namespaceEscape keys, or use an SDK

If the operation name is missing from your error, because a library or framework wrapped it, turn on the SDK's debug logging or use one of the three "show me the XML" tricks further down. Seeing the body you actually send settles the question in seconds.

Whatever the operation, the routine that finds the cause fastest is the same four steps:

  1. Copy the operation name from the error, the word before "operation".
  2. Find it in the table and check your request against the rule listed for it. Most of the time, this is where you stop.
  3. Print the body your code sends, with the CLI, Python or JavaScript trick below, if the rule looks satisfied.
  4. Fix the request and try it on a test bucket first, especially for lifecycle, versioning and Object Lock changes, which affect every object in the bucket.

DeleteObjects: the empty list and the 1,000-key limit

This is the cause behind the most-viewed question about this error, and it is the one that hit Jake's shop. DeleteObjects removes many objects in one request, and it has two firm limits. A request can contain up to 1,000 keys, and it must contain at least one. Both "zero" and "1,001" come back as MalformedXML.

Here is what a typical cleanup job sends on a night when nothing matched. In Python, Delete={"Objects": []} becomes this body:

<Delete xmlns="http://s3.amazonaws.com/doc/2006-03-01/" />

A delete instruction with nothing inside it. The JavaScript SDK v3 sends the same empty element, with an XML declaration in front. Neither SDK stops you, and S3 refuses it.

Jake: "So the job broke because business was slow? That feels backwards."

Ethan: "It does. For six weeks there was always something old to delete. The first night there wasn't, the job asked S3 to delete nothing, and S3 treats 'delete nothing' as a broken request instead of an easy one."

The other limit bites the opposite way. A job that worked with a few hundred keys fails the day a folder grows past 1,000, because one request can carry at most 1,000. Both problems have the same fix: send keys in batches of at most 1,000, and send nothing at all when there is nothing to send.

The Python fix: a batch helper that never sends an empty list

import boto3

s3 = boto3.client("s3")

def delete_keys(bucket, keys):
    """Delete a list of keys, 1,000 per request. An empty list sends nothing."""
    for i in range(0, len(keys), 1000):
        batch = [{"Key": k} for k in keys[i:i + 1000]]
        resp = s3.delete_objects(Bucket=bucket, Delete={"Objects": batch, "Quiet": True})
        for err in resp.get("Errors", []):
            print("could not delete", err["Key"], "-", err["Code"])

def delete_prefix(bucket, prefix):
    """Delete everything under a prefix. Each listed page holds at most 1,000 keys."""
    pages = s3.get_paginator("list_objects_v2").paginate(Bucket=bucket, Prefix=prefix)
    for page in pages:
        keys = [obj["Key"] for obj in page.get("Contents", [])]
        if keys:
            delete_keys(bucket, keys)

The loop does both jobs at once. range(0, 0, 1000) is empty, so an empty list produces no request at all, and slices of 1,000 keep every request under the limit. With 2,345 keys it sends three requests of 1,000, 1,000 and 345, and with zero keys it sends none.

"Quiet": True changes what comes back. In the default verbose mode, S3 reports the result for every key. In quiet mode, the response lists only the keys that could not be deleted, so a clean run returns nothing to print, and the error loop above shows only real problems. That loop matters, because DeleteObjects can succeed as a request while individual keys fail, most often with AccessDenied. A 200 response does not mean every object is gone.

delete_prefix uses a paginator, which fetches the listing one page at a time. Each page of list_objects_v2 holds at most 1,000 keys, so every page fits in one delete request, and the if keys line skips an empty page, which is exactly what you get when the prefix matches nothing.

The JavaScript fix (SDK v3)

import { DeleteObjectsCommand } from "@aws-sdk/client-s3";

// Delete a list of keys, 1,000 per request. An empty list sends nothing.
export async function deleteKeys(s3, bucket, keys) {
  for (let i = 0; i < keys.length; i += 1000) {
    const Objects = keys.slice(i, i + 1000).map((Key) => ({ Key }));
    const out = await s3.send(new DeleteObjectsCommand({ Bucket: bucket, Delete: { Objects, Quiet: true } }));
    for (const err of out.Errors ?? []) console.log("could not delete", err.Key, "-", err.Code);
  }
}

You pass in the S3Client you already have, so the function works in Lambda, in Node scripts and in tests alike. The same loop logic applies: zero keys means zero requests, and 2,345 keys means three requests of 1,000, 1,000 and 345.

The AWS CLI version

The CLI's high-level command does the batching for you. aws s3 rm s3://jakes-shop-photos/old-orders/ --recursive lists the prefix and deletes in batches, and it does nothing, without an error, when the prefix is empty. If you need the low-level delete-objects call, put the list in a file and keep it between 1 and 1,000 keys:

aws s3api delete-objects --bucket jakes-shop-photos --delete file://delete.json

where delete.json looks like this:

{"Objects": [{"Key": "old-orders/00001.json"}, {"Key": "old-orders/00002.json"}], "Quiet": true}

For versioned buckets, where a delete only adds a delete marker and the old versions stay behind, our guide to restoring deleted S3 objects has a full script that works with versions and markers in batches of 1,000.

Older SDKs: the same two limits

The same limits apply to every SDK, because they belong to S3, not to the library. In the Java SDK, the error shows up as Status Code: 400, AWS Service: Amazon S3, AWS Error Code: MalformedXML on deleteObjects, and the fix is the same batch of at most 1,000 keys. In Go, PHP and .NET, the field names change, but the two rules do not: at least one key, at most 1,000.

"MalformedXML when uploading": your PUT went to the bucket, not to a file

The second most common story starts in a browser. Someone uploads a photo with JavaScript, the request comes back with MalformedXML, and nothing in the upload code mentions XML at all. Here is the shape of the code that does it:

const xhr = new XMLHttpRequest();
xhr.open("PUT", "https://jakes-shop-photos.s3.amazonaws.com", true);
xhr.send(formData);

The address has a bucket in it, but no object key: there is nothing after the last slash. To S3, a PUT to the bucket's own address is not an upload. It is the CreateBucket request, which is exactly PUT / on the bucket's address with an optional XML body describing the new bucket. S3 reads your photo as that XML document, finds a JPEG where it expected a CreateBucketConfiguration, and answers MalformedXML.

Jake: "So it's like posting a parcel with the street name on it but no house number, and the post office decides I must be trying to register a new street."

Ethan: "Ha, yes. And then it complains that my parcel is a terrible street application."

The fix is to put the object key, the file's name inside the bucket, in the URL:

xhr.open("PUT", "https://jakes-shop-photos.s3.amazonaws.com/photos/" + encodeURIComponent(file.name), true);
xhr.setRequestHeader("Content-Type", file.type);
xhr.send(file);

Two more details in that version are worth copying. It sends the file itself as the body, not a FormData wrapper, because a PUT stores exactly the bytes it receives; form uploads belong to S3's separate POST upload method, which uses a signed policy. And it encodes the file name, so a photo called new phone (1).jpg does not break the address. The same applies to fetch:

await fetch(uploadUrl, { method: "PUT", body: file, headers: { "Content-Type": file.type } });

In a real app, uploadUrl is almost always a presigned URL that your server creates for one specific key, because a browser should never hold your AWS keys. Our guide to presigned URLs shows the server side. The good news is that the SDK's presigner refuses to build an upload URL without a key: in Python, generate_presigned_url("put_object", Params={"Bucket": "jakes-shop-photos"}) fails at once with Missing required parameter in input: "Key", so a key-less URL usually means one built by hand or one whose key came through as an empty string.

If the browser blocks the upload before it reaches S3 at all, with a message about CORS in the console, that is a different problem, and our S3 CORS guide has the working configuration.

When the bucket name is the problem, not the key

A close cousin of this mistake creates real buckets by accident. Code that builds the URL from a variable that turns out to be empty can send a PUT to an address you did not intend. If you ever see a CreateBucket call in CloudTrail that nobody meant to make, or BucketAlreadyOwnedByYou from an "upload", this is the reason. Our guide to the conflicting conditional operation error covers what happens when bucket creation and deletion collide.

Multipart uploads with no parts, and empty files

Big files go to S3 in pieces with a multipart upload: you start the upload, send the parts, then call CompleteMultipartUpload with the list of parts to stitch together. If that list is empty, the completion request is an empty <CompleteMultipartUpload/> element, and S3 answers MalformedXML. The SDKs send it without complaint, just as they send an empty delete list.

The usual way to end up with zero parts is a zero-byte file. A streaming uploader that always uses multipart reads the file, finds no data, uploads no parts, then asks S3 to complete an upload of nothing. Developers who hit this in Java and in Node report the same fix: give empty files their own path.

  • Empty or small file: use a plain PutObject. A zero-byte object is perfectly valid in S3; it just cannot be built from zero parts.
  • Large file: multipart, with every part except the last at least 5 MB. A smaller middle part fails with a different error, EntityTooSmall.
  • Part list order: ascending part numbers, each with the ETag S3 returned for that part. A wrong order fails with InvalidPartOrder, and a wrong ETag with InvalidPart.

The high-level helpers already handle this. boto3's upload_file and the CLI's aws s3 cp choose a single PUT for small files and multipart for large ones, so a zero-byte file goes up as an ordinary object. The trouble starts with hand-rolled multipart code, usually written to stream data of unknown size. In that code, check the byte count before you start: if nothing was read, call PutObject with an empty body instead.

One more quirk lives here. CompleteMultipartUpload can take a long time on big files, so S3 sends a 200 OK header straight away and keeps the connection alive while it works. An error can still arrive inside that 200 response. The SDKs read the body and raise the error properly, but code that calls the API over plain HTTP has to read the body too, instead of trusting the status code. Our guide to EntityTooLarge covers part sizes and the 5 GB single-upload limit.

Lifecycle rules: Filter, And, and expired delete markers

Lifecycle rules are the most common cause after deletes, because the shape of a rule has changed over the years and plenty of old examples still show the earlier format. Three rules cover nearly every MalformedXML on PutBucketLifecycleConfiguration.

1. Every rule needs a Filter. A rule must say which objects it applies to, with a Filter element, unless it uses the older top-level Prefix element, which is deprecated. A rule with neither is rejected. The classic Python example that fails looks like this, with no Filter anywhere:

s3.put_bucket_lifecycle_configuration(
    Bucket="jakes-shop-photos",
    LifecycleConfiguration={"Rules": [{
        "ID": "old-logs",
        "Status": "Enabled",
        "Expiration": {"Days": 30},
    }]},
)

Add a Filter and it is accepted. An empty Filter means "every object in the bucket"; a Filter with a Prefix limits the rule to one folder:

s3.put_bucket_lifecycle_configuration(
    Bucket="jakes-shop-photos",
    LifecycleConfiguration={"Rules": [{
        "ID": "old-logs",
        "Status": "Enabled",
        "Filter": {"Prefix": "logs/"},      # or "Filter": {} for the whole bucket
        "Expiration": {"Days": 30},
    }]},
)

2. One condition per Filter, unless you use And. A Filter holds exactly one of these: Prefix, Tag, ObjectSizeGreaterThan, ObjectSizeLessThan or And. To combine a prefix with a tag, or a prefix with a size, wrap them in And. In the CLI's JSON that looks like this:

{"Rules": [{
  "ID": "logs-tagged",
  "Status": "Enabled",
  "Filter": {"And": {"Prefix": "logs/", "Tags": [{"Key": "temp", "Value": "yes"}]}},
  "Expiration": {"Days": 7}
}]}

Inside And, the JSON field is Tags, a list, while a single-condition Filter uses Tag, one object. Mixing those two up is an easy slip.

3. ExpiredObjectDeleteMarker stands alone. In a versioned bucket, ExpiredObjectDeleteMarker: true tells S3 to clean up delete markers that no longer hide any older version. It cannot sit in the same Expiration as Days or Date. If you want both "expire current objects after 30 days" and "tidy up leftover delete markers", put them in two separate rules. A tidy-up rule that is accepted looks like this:

{"Rules": [{
  "ID": "tidy",
  "Status": "Enabled",
  "Filter": {},
  "Expiration": {"ExpiredObjectDeleteMarker": true},
  "NoncurrentVersionExpiration": {"NoncurrentDays": 30}
}]}

A few smaller rules round it out. Status takes exactly Enabled or Disabled, spelled with a capital letter. Days must be a whole number above zero. A bucket can hold up to 1,000 lifecycle rules.

And one point that causes more damage than any error: PutBucketLifecycleConfiguration replaces the bucket's entire lifecycle configuration. Sending one new rule deletes every rule that was there before. The safe way to add a rule is always the same three steps:

  1. Save the current rules with aws s3api get-bucket-lifecycle-configuration --bucket jakes-shop-photos > lifecycle.json, and keep that file as your backup.
  2. Add your rule to the Rules list, and send only the Rules list back. The saved file also holds a TransitionDefaultMinimumObjectSize value, and passing it back inside the configuration fails with Unknown parameter in LifecycleConfiguration: "TransitionDefaultMinimumObjectSize"; that setting travels as its own --transition-default-minimum-object-size option.
  3. Put the whole list back with put-bucket-lifecycle-configuration and file://, then read it again to check that every rule is there.

The lifecycle guide linked at the end of this section has the exact jq command for step 2. If the first command answers NoSuchLifecycleConfiguration, the bucket has no rules yet, and you can put a new configuration directly.

Jake: "So the Filter is the bit that says which shelves the clear-out applies to, and without it, S3 refuses to guess?"

Ethan: "Right. It won't assume 'all shelves' for you. You have to write the empty filter that means 'all shelves' yourself."

When your rule is accepted but objects never disappear, that is a different problem; our guide to an S3 lifecycle rule not working walks through every reason, from versioning to timing.

Terraform: where the Filter rule bites

Terraform's AWS provider handles the missing-Filter case for you. In aws_s3_bucket_lifecycle_configuration, a rule with no filter and no prefix applies to every object, exactly like an empty filter {}. Writing it out makes the intent clear to the next person who reads the file:

resource "aws_s3_bucket_lifecycle_configuration" "photos" {
  bucket = aws_s3_bucket.photos.id

  rule {
    id     = "expire-old-orders"
    status = "Enabled"

    filter {
      prefix = "old-orders/"
    }

    expiration {
      days = 90
    }
  }
}

The rule that does bite in Terraform is the second one. A filter block must be empty or hold exactly one of prefix, tag, and, object_size_greater_than or object_size_less_than. A prefix and a tag together go inside an and block, with the tags written as a map. The top-level prefix argument on a rule is deprecated and will be removed in the next major version of the provider, so new code should use filter.

Versioning and MFA Delete: there is no "Disabled"

Once a bucket has had versioning switched on, it can never go back to the unversioned state it started in. It can only be Enabled or Suspended, and those are the only two values PutBucketVersioning accepts for Status. So the command people naturally try, Status=Disabled, is sent by the CLI and rejected by S3 as MalformedXML.

aws s3api put-bucket-versioning --bucket jakes-shop-photos --versioning-configuration Status=Suspended

Suspending stops S3 from creating new versions. Every version already stored stays where it is, and keeps costing storage, until you delete it or a lifecycle rule expires it.

Jake: "So it's like a till roll. You can stop printing receipts, but the ones already printed stay in the drawer until you throw them out."

Ethan: "Exactly. 'Disabled' would mean the receipts never existed, and S3 doesn't offer that once you've started printing."

The confusing part is that Disabled is a real value in the same request, for a different field. MFADelete, the setting that demands a one-time code before anyone can delete a version or change versioning, takes Enabled or Disabled. So a command that turns off MFA Delete has to keep Status at Enabled or Suspended while setting MFADelete=Disabled:

aws s3api put-bucket-versioning --bucket jakes-shop-photos \
  --versioning-configuration Status=Enabled,MFADelete=Disabled \
  --mfa "arn:aws:iam::111122223333:mfa/root-account-mfa-device 123456"

The --mfa value is the MFA device's ARN, a space, and the current six-digit code. Two more conditions apply, and both trip people up. Only the bucket owner's root user can change MFA Delete, so run this with the root user's credentials and its own MFA device, not an IAM user's. And it cannot be done in the S3 console at all; the CLI or the API is the only way.

Object Lock: you can turn it on, but never off

PutObjectLockConfiguration has a similar trap. The ObjectLockEnabled field accepts exactly one value, Enabled. There is no Disabled, so a request that tries to switch Object Lock off is rejected as MalformedXML, from boto3, the CLI or anywhere else.

That is by design. After Object Lock is enabled on a bucket, it cannot be disabled, and versioning on that bucket cannot be suspended. What you can change is the bucket's default retention: the mode and the period that apply to new objects. The period is either Days or Years, never both in the same rule, and sending both is the other MalformedXML on this operation:

aws s3api put-object-lock-configuration --bucket jakes-shop-photos \
  --object-lock-configuration '{"ObjectLockEnabled": "Enabled", "Rule": {"DefaultRetention": {"Mode": "GOVERNANCE", "Days": 30}}}'

If you are trying to get rid of a locked bucket, our guide to S3 delete bucket Access Denied explains what retention and legal holds allow, and what they never will.

Restoring from Glacier: Tier goes inside GlacierJobParameters

Restoring an archived object from S3 Glacier Flexible Retrieval or S3 Glacier Deep Archive takes a RestoreRequest with two pieces of information: how many days the temporary copy should stay available, and how fast you want it back. The speed setting, Tier, does not sit at the top of the request. It lives inside GlacierJobParameters. This version, which looks perfectly reasonable, fails with MalformedXML:

s3.restore_object(
    Bucket="jakes-shop-photos",
    Key="video.mov",
    RestoreRequest={"Days": 1, "Tier": "Standard"},
)

The working version moves Tier one level down:

s3.restore_object(
    Bucket="jakes-shop-photos",
    Key="video.mov",
    RestoreRequest={"Days": 1, "GlacierJobParameters": {"Tier": "Standard"}},
)

The CLI form is the same shape in JSON:

aws s3api restore-object --bucket jakes-shop-photos --key video.mov \
  --restore-request '{"Days": 1, "GlacierJobParameters": {"Tier": "Standard"}}'

Tier takes Standard, Bulk or Expedited, and Days is required for a normal restore. The restore is not instant: the object becomes readable when the job finishes, and the temporary copy is billed for the days you asked for. Our guide to S3 storage classes explains which archive tier takes how long.

Replication rules: a Filter needs Priority and DeleteMarkerReplication

Replication rules grew new parts over time, and a rule that mixes the old and new styles is rejected. The current style uses a Filter, and once a rule has a Filter, it must also have a Priority and a DeleteMarkerReplication element. Leaving them out gives MalformedXML on PutBucketReplication. A complete rule that replicates the whole bucket looks like this:

{
  "Role": "arn:aws:iam::111122223333:role/s3-replication",
  "Rules": [{
    "ID": "all-to-backup",
    "Status": "Enabled",
    "Priority": 1,
    "Filter": {},
    "DeleteMarkerReplication": {"Status": "Disabled"},
    "Destination": {"Bucket": "arn:aws:s3:::jakes-shop-backup"}
  }]
}

Save it as replication.json and send it with aws s3api put-bucket-replication --bucket jakes-shop-photos --replication-configuration file://replication.json. Two details are worth knowing. If the Filter uses a tag, DeleteMarkerReplication must be Disabled, because S3 does not replicate delete markers for tag-based rules. And the old top-level Prefix on a replication rule is deprecated, so new rules should put the prefix inside the Filter. Both buckets also need versioning enabled before S3 accepts any replication configuration, although that mistake produces a different, clearer error.

See the exact XML your code sends

When the table at the top does not point at an obvious cause, stop guessing and look at the request. All three main tools can show you the body they send, and once you see it next to the rules for that operation, the problem is usually plain.

AWS CLI: --debug and one grep

The CLI's --debug output is long, but the request body sits on one line. This pulls it out:

aws s3api put-bucket-lifecycle-configuration --bucket jakes-shop-photos \
  --lifecycle-configuration file://lifecycle.json \
  --debug 2>&1 | grep -o "'body': b'[^']*'"

For the rule without a Filter, the line it prints shows the problem at a glance: <Rule><ID>old-logs</ID><Status>Enabled</Status><Expiration><Days>30</Days></Expiration></Rule>, a rule with no Filter in it. This sends the real request, so run it against a test bucket, or expect S3's answer to follow.

Python: print the body without sending anything

boto3 lets you hook into the moment just before a request goes out. This hook prints the method, the path and the body, then hands back a fake empty "OK" so nothing reaches S3:

import boto3
from botocore.awsrequest import AWSResponse

def show_and_stop(request, **kwargs):
    body = request.body
    if hasattr(body, "read"):
        body = body.read()
    if isinstance(body, bytes):
        body = body.decode("utf-8", "replace")
    print(request.method, request.url.split("amazonaws.com")[-1])
    print(body or "(empty body)")
    class Raw:
        def stream(self, **kwargs):
            yield b""
    return AWSResponse(request.url, 200, {}, Raw())   # pretend S3 said OK; nothing was sent

s3 = boto3.client("s3", region_name="us-east-1")
s3.meta.events.register("before-send.s3.*", show_and_stop)

s3.delete_objects(Bucket="jakes-shop-photos", Delete={"Objects": []})

Run it, and it prints POST /?delete followed by <Delete xmlns="http://s3.amazonaws.com/doc/2006-03-01/" />: the empty delete that broke Jake's job, caught on your own screen. Swap in any other call to see its body. boto3 signs a request before this hook runs, so it still needs credentials to be configured, but those credentials are never used to send anything.

JavaScript SDK v3: a one-step middleware

s3.middlewareStack.add((next) => async (args) => {
  console.log("BODY:", String(args.request.body ?? "(empty)"));
  return next(args);
}, { step: "finalizeRequest", name: "printBody" });

Add it to your S3Client once, and every request prints its body just before it goes out. Remove it when you are done, because it will also print every object you upload.

Jake: "So instead of guessing what the courier's machine printed, we read the form before it leaves the shop."

Ethan: "Yes. Five minutes of reading the actual form beats an hour of reading old forum answers."

Writing the XML yourself: special characters in keys

Most people never build S3's XML by hand, but some code does: requests signed and sent with a plain HTTP library, tools for S3-compatible storage, or old scripts that predate the SDKs. That code has to follow two kinds of rules. The XML must be well formed, with every element closed, the S3 namespace http://s3.amazonaws.com/doc/2006-03-01/ on the root element, and elements in the order the schema expects. And the text inside the elements must be escaped properly.

Escaping matters most for object keys, because keys can contain characters that XML treats specially. Inside an XML request, these have to be replaced:

Character in the keyWrite it as
&&amp;
<&lt;
>&gt;
Carriage return (\r)&#13; or &#x0D;
Newline (\n)&#10; or &#x0A;

A raw & or < breaks the XML outright, which gives you a true MalformedXML. The carriage return is sneakier. XML parsers turn every carriage return into a newline before anyone reads the text, so a key containing a raw \r arrives at S3 as a different key, and the request can quietly act on the wrong object, or on nothing, without any error at all. The SDKs escape all of this for you; boto3, for example, writes a carriage return in a key as &#xD;. That is one more reason to let an SDK build the XML whenever you can.

Troubleshooting by symptom

MalformedXML on DeleteObjects only on some runs

The runs that fail had nothing to delete, or more than 1,000 keys. Skip the request when the list is empty, and send batches of at most 1,000.

MalformedXML when uploading a file from the browser

The PUT went to the bucket's address with no object key, so S3 treated it as CreateBucket. Add the key to the URL, and send the file itself as the body.

MalformedXML when uploading an empty file

Your uploader used multipart and completed it with zero parts. Upload zero-byte and small files with PutObject.

MalformedXML on a lifecycle rule copied from an old example

The rule has no Filter. Add "Filter": {} for the whole bucket, or a Filter with a Prefix for one folder.

MalformedXML on a lifecycle rule with a prefix and a tag

Two conditions sit directly in the Filter. Wrap them in And, with the tags in a Tags list.

MalformedXML on a delete-marker cleanup rule

ExpiredObjectDeleteMarker shares an Expiration with Days or Date. Move it into its own rule.

MalformedXML when turning off versioning or MFA Delete

You used Status=Disabled. Use Suspended to stop versioning, and keep Status at Enabled or Suspended while setting MFADelete=Disabled with the root user's MFA code.

MalformedXML when turning off Object Lock

Object Lock cannot be turned off; the only valid value is Enabled. Change the default retention instead, using Days or Years, never both.

MalformedXML on a Glacier restore

Tier is at the top level. Move it inside GlacierJobParameters, and include Days.

The SDK accepted the request, so why did S3 reject it?

The SDKs check types and required fields, not S3's rules about counts, combinations and allowed values. Print the body with one of the tricks above and compare it with the rules for that operation.

S3 MalformedXML: frequently asked questions

What does "The XML you provided was not well-formed or did not validate against our published schema" mean?

It is S3's MalformedXML error, HTTP 400. The XML in your request broke one of S3's rules. When an SDK or the CLI built the XML, it is almost always a rule about content, such as an empty list or a missing Filter, not broken XML.

Why does S3 DeleteObjects return MalformedXML?

The request held no keys or more than 1,000. DeleteObjects accepts between 1 and 1,000 keys per request. Skip the call when the list is empty and split larger lists into batches of 1,000.

Why do I get MalformedXML when deleting an empty list of objects?

An empty list becomes an empty Delete element, which S3 rejects. The SDKs send it anyway. Check the length of your list and make no request when it is zero.

How many objects can I delete in one S3 request?

Up to 1,000 keys per DeleteObjects request. For more, send several requests, or use aws s3 rm with --recursive, which batches for you.

Why do I get MalformedXML when uploading to S3 from the browser?

The upload URL had no object key, so S3 read the PUT as a request to create the bucket and tried to parse your file as bucket settings. Put the key in the URL, and send the file itself as the body.

Why does CompleteMultipartUpload return MalformedXML?

The list of parts was empty, usually because the file had zero bytes. Upload empty and small files with a plain PutObject, and keep every part except the last at least 5 MB.

Why does PutBucketLifecycleConfiguration return MalformedXML?

Most often the rule has no Filter, has two conditions in a Filter without And, or combines ExpiredObjectDeleteMarker with Days or Date. Add a Filter, use And for two conditions, and give the delete-marker cleanup its own rule.

Is the Filter required in an S3 lifecycle rule?

Yes, unless the rule uses the deprecated top-level Prefix. An empty Filter applies the rule to every object in the bucket; a Filter with a Prefix limits it to one folder.

How do I disable versioning on an S3 bucket?

You can't return a bucket to unversioned. Set Status to Suspended to stop new versions. Existing versions stay until you delete them or a lifecycle rule expires them.

How do I disable MFA Delete on an S3 bucket?

With the root user's credentials, run put-bucket-versioning with Status=Enabled,MFADelete=Disabled and --mfa set to the device ARN, a space and the current code. It is not possible in the console.

Can I disable Object Lock on an S3 bucket?

No. Once enabled, Object Lock stays on and versioning cannot be suspended. You can change the default retention mode and period for new objects, using Days or Years but not both.

Why does restore_object return MalformedXML?

Tier is at the top level of RestoreRequest. It belongs inside GlacierJobParameters, as in RestoreRequest={"Days": 1, "GlacierJobParameters": {"Tier": "Standard"}}.

Why does PutBucketReplication return MalformedXML?

A rule with a Filter is missing Priority or DeleteMarkerReplication. Add both. With a tag in the Filter, DeleteMarkerReplication must be Disabled.

How can I see the XML my AWS SDK sends to S3?

In the CLI, add --debug and grep for the body line. In boto3, register a before-send hook that prints request.body. In the JavaScript SDK v3, add a finalizeRequest middleware that logs args.request.body.

Does boto3 validate S3 requests before sending them?

Only types and required fields. Counts, combinations and allowed values, such as an empty delete list or a lifecycle rule without a Filter, are checked by S3, which is why the error appears only when the request reaches it.

Which special characters in S3 keys must be escaped in XML?

Ampersand, less-than and greater-than, plus carriage returns and newlines, written as character references such as &#13;. The SDKs escape them for you; hand-built XML must do it itself.

Ethan's fix for the shop took four lines: check whether the list was empty, and send the rest in slices of 1,000. The next quiet Monday, the job logged "nothing to delete" and finished in under a second. Jake read the new log line over his shoulder and grinned. "So the depot never sees the empty form now." Ethan nodded. "It never even gets printed. And if the shop ever gets so busy that we have two thousand old photos in a night, it sends two forms instead of one." Jake considered that for a moment. "I'd like that problem."

📌 If you keep one line from this page

MalformedXML usually means valid XML for a request S3 refuses: find the operation name, then check the one rule that operation is famous for.

Deletes: 1 to 1,000 keys. Uploads: a key in the URL. Lifecycle: a Filter. Versioning: Suspended, never Disabled.

Revision note. Written October 13, 2026, for everyone whose job failed on the night it had nothing to do. As the saying goes, look before you leap; a one-line check before the call saves the whole night.

Related