// Gmail · Apps Script

Label emails matching a search query in Gmail.

Apply a Gmail label to every email matching a search query using Apps Script — handles thread-level labeling, missing labels, and large result sets above the 500-thread cap.

I want to bulk-label a set of emails matching a Gmail search string without clicking through hundreds of threads by hand.

The script

copy · paste · trigger
labelBySearch.gs
Apps Script
// labelBySearch.gs
// Applies a label to every thread matching a Gmail search query.
// Run once manually, or attach to a time-driven trigger.

function labelBySearch() {
  var QUERY = 'from:invoices@acme.com is:unread';
  var LABEL_NAME = 'Acme/Invoices';
  var PAGE_SIZE = 100;

  var label = GmailApp.getUserLabelByName(LABEL_NAME)
             || GmailApp.createUserLabel(LABEL_NAME);

  var start = 0;
  var threads;

  do {
    threads = GmailApp.search(QUERY, start, PAGE_SIZE);
    for (var i = 0; i < threads.length; i++) {
      threads[i].addLabel(label);
    }
    start += threads.length;
  } while (threads.length === PAGE_SIZE);
}

Need a variant? Gnaw writes a custom version from one sentence — fields, triggers, edge cases handled.

Walkthrough

Get or create the label before you touch any threads

GmailApp.getUserLabelByName returns null when the label does not exist yet — it does not create it for you, and addLabel will throw if you hand it null. The one-liner fix is the short-circuit: getUserLabelByName(LABEL_NAME) || createUserLabel(LABEL_NAME). The first time the script runs, the label is created and returned. Every subsequent run finds it and skips creation. I keep this pattern in a utils file shared across all my Gmail scripts because forgetting it produces a cryptic 'Cannot call method addLabel of null' error that wastes ten minutes the first time you see it.

Nested label names use a forward slash: 'Acme/Invoices' creates an Invoices sublabel under Acme. If the parent Acme label does not exist yet, createUserLabel creates the full path in one call.

Why addLabel touches the whole thread, not just one message

GmailApp.search returns GmailThread objects, not GmailMessage objects. Calling addLabel on a thread applies the label to every message inside that thread. That is usually what you want when working with search queries, because a label applied to only the first message in a conversation still hides the rest from your filtered view.

If you genuinely need per-message labeling — say, to label only the messages that matched the query inside a mixed thread — you need GmailApp.searchMessages (available via the Gmail advanced service, not GmailApp) or iterate thread.getMessages() and filter by date or sender yourself. For the common case of 'label everything from this sender,' thread-level labeling is correct and faster.

Paging past the 500-thread hard cap

GmailApp.search accepts three arguments: query, start, and max. The max ceiling is 500 per call, and the default when you omit start and max is also 500. If your search matches more than 500 threads, a single call silently drops the rest.

The do-while loop above pages in batches of 100 (a smaller batch reduces the chance of hitting Apps Script's 6-minute execution limit on large mailboxes). It stops when a page comes back with fewer results than PAGE_SIZE, which is the signal that you have reached the end. If you have tens of thousands of matching threads, consider running this on a time-driven trigger and storing the start offset in PropertiesService so each execution picks up where the last one stopped.

Want a custom version?

Describe your sheet and the rule you want. Gnaw writes the Apps Script — fields, triggers, edge cases — in one shot.

FAQ

4 questions
Why does my label disappear from some messages after applying it?
It did not disappear — you applied the label to the thread, and Gmail's web UI shows label chips at the thread level in list view. Individual messages inside the thread that arrived before the label was applied still carry it; check the message detail view to confirm. If a filter with 'remove label' is also set on that search, the filter runs on new incoming mail and will strip it again.
Can I use the same script to remove a label instead of adding one?
Yes. Replace threads[i].addLabel(label) with threads[i].removeLabel(label). getUserLabelByName still needs to find the label first — if it returns null the label does not exist and there is nothing to remove, so add a null guard: if (label) { threads[i].removeLabel(label); }.
GmailApp.search is not finding emails I can see in Gmail — what is wrong?
The most common cause is that GmailApp.search runs as the script owner, not necessarily as the account you are looking at in the browser. Verify the active account under File > Project settings in the Apps Script editor. Also note that GmailApp.search does not support every Gmail search operator — notably, it drops results from the Spam and Trash tabs even when you include in:spam or in:trash in the query string.
How do I run this automatically on new mail?
In the Apps Script editor, open Triggers (the clock icon), add a new trigger for labelBySearch, set event source to 'Time-driven,' and pick an interval (every 15 minutes is the minimum). The do-while loop ensures it catches any backlog on each run. For real-time labeling on arrival, Gmail's built-in filter UI is faster and does not consume Apps Script quota — use the script only when the filter UI cannot express your rule (complex OR logic, label hierarchies, etc.).