Guide
Gmail API batchDelete: Bulk Delete Messages
One batchDelete call can erase 1,000 messages in a single request, and none of them pass through the trash on the way out. That power is why the method requires Gmail's broadest OAuth scope, and why most bulk-cleanup scripts should reach for a recoverable alternative first. This guide covers the 1,000-ID limit, the scope requirement, the quota math against trash-based deletion, and the list-first patterns that keep a bad filter from destroying a mailbox.
Written by Qasim Muhammad Staff SRE
Command references used in this guide: nylas email list and nylas email delete.
What does messages.batchDelete do?
The users.messages.batchDelete method permanently deletes a list of messages by ID in one HTTP request. Per the API reference, it "deletes many messages by message ID" and "provides no guarantees that messages were not already deleted or even existed at all." This is permanent deletion, not a move to Trash, so the 30-day recovery window that trashed messages get doesn't apply.
The method authorizes against exactly one scope: https://mail.google.com/, Gmail's full-access scope. Narrower scopes like gmail.modify don't work, which has two consequences. Your OAuth consent screen asks users for complete mailbox control, and apps using this scope face Google's restricted-scope verification review. The single-message messages.delete shares the same constraint; its own reference says it "immediately and permanently deletes the specified message" and recommends messages.trash instead.
What is the 1,000-ID limit per request?
The ids array in a batchDelete request caps at 1,000 message IDs. Google's Python client documentation describes the field as "The IDs of the messages to delete. There is a limit of 1000 ids per request." Clearing 25,000 messages therefore takes a minimum of 25 chunked requests.
The chunking pattern below collects IDs with paginated messages.list calls (5 quota units each), then deletes in 1,000-ID slices. The full sweep of 25,000 messages costs 1,500 quota units (250 for the 50 list calls, 1,250 for the 25 deletes), well inside the 6,000-units-per-minute per-user budget.
def collect_ids(service, query):
"""Page through messages.list and return all matching IDs."""
ids, token = [], None
while True:
resp = service.users().messages().list(
userId="me", q=query, maxResults=500, pageToken=token
).execute()
ids += [m["id"] for m in resp.get("messages", [])]
token = resp.get("nextPageToken")
if not token:
return ids
ids = collect_ids(service, "from:noreply@example.com older_than:1y")
print(f"about to permanently delete {len(ids)} messages")
# batchDelete accepts at most 1,000 IDs per request
for i in range(0, len(ids), 1000):
service.users().messages().batchDelete(
userId="me", body={"ids": ids[i:i + 1000]}
).execute()Exceeding the cap fails the whole request, so always slice client-side. The response body is empty on success, which means your script's own count of submitted IDs is the only record of what was removed.
batchDelete vs trash vs delete: which should you use?
The Gmail API has three deletion paths, and they differ on recoverability, batch size, scope, and quota cost. Google's usage limits page prices batchDelete and batchModify at 50 units each, messages.trash at 20, and messages.delete at 10. The recoverable bulk option is batchModify with the TRASH label, since Google's labels guide lists TRASH as a system label that can be manually applied.
| Method | Recoverable | Max per request | Minimum scope | Quota units |
|---|---|---|---|---|
| messages.batchDelete | No | 1,000 | https://mail.google.com/ | 50 |
| batchModify + TRASH label | Yes (30 days) | 1,000 | gmail.modify | 50 |
| messages.trash | Yes (30 days) | 1 | gmail.modify | 20 |
| messages.delete | No | 1 | https://mail.google.com/ | 10 |
The quota math favors batching heavily. Trashing 1,000 messages with individual messages.trash calls costs 20,000 units, more than 3x the 6,000-unit per-user-per-minute limit, so the loop throttles with 429 errors. One batchModify call covers the same 1,000 messages for 50 units: a 400x reduction. Reserve batchDelete for cases where permanent, immediate removal is the requirement, like purging sensitive data under a retention policy.
How do you avoid deleting the wrong messages?
A batchDelete guardrail is a check that runs between collecting IDs and submitting the request. The method itself offers none: no dry-run flag, no undo, and an empty response body. Three checks cover most failure modes, and they cost at most a few hundred quota units on a 10,000-message sweep.
- Count before you delete — print
len(ids)and require a typed confirmation when it exceeds your expected ceiling. A query likeolder_than:1ywith a typo (older_than:1d) changes the match set by orders of magnitude. - Sample the matches — fetch 5 random IDs with
messages.getinmetadataformat and print their subjects and senders. A wrong-folder filter is obvious in 5 samples. - Export, then delete — write the matched messages to JSON first. The backup guide covers a full export pipeline you can run before any destructive pass.
Staging the cleanup as trash-first also works as a guardrail: run batchModify with the TRASH label today, then let a second job call batchDelete on the TRASH label 7 days later. Anything flagged wrongly in the meantime is one click to restore inside Gmail's 30-day window.
How do you bulk delete from the CLI?
The nylas email delete command removes a message by ID across Gmail, Outlook, and 4 other providers, and deletion moves the message to the provider's trash rather than erasing it. Piping nylas email list --json through jq filters and xargs gives you a bulk pipeline with no Google Cloud project or restricted-scope OAuth review.
# Count matches first (guardrail #1)
nylas email list --from noreply@example.com --json --limit 200 | jq length
# Bulk delete: read messages from one sender
nylas email list --from noreply@example.com --json --limit 200 | \
jq -r '.[] | select(.unread == false) | .id' | \
xargs -I {} nylas email delete {} --yes
# Review what's sitting in trash afterwards
nylas email list --folder TRASH --limit 20Because the CLI path trashes rather than purges, it pairs with Gmail's 30-day recovery window instead of fighting it. The delete and archive guide covers thread deletion, date-based filters, and per-provider trash retention in detail.
Next steps
- Delete and archive email from the CLI — single-message, thread, and filtered bulk cleanup patterns
- Gmail API: list spam and trash — read the TRASH label to audit what a cleanup run moved
- Gmail API quotas — per-method costs and the 6,000-unit per-minute budget explained
- Back up emails to JSON — export matched messages before a destructive pass
- Full command reference — every flag and subcommand documented