Export Gmail to Google Sheets with Apps Script (and When an Extension Is Faster)
GmailApp.search() in batches and writes rows with setValues(), then run it and approve access. Each run stops at 6 minutes, and a consumer account can read about 20,000 emails a day, so large exports need a resumable script like the one below.Apps Script is Google's built-in JavaScript platform. It runs on Google's servers, saves your code to Drive, and needs nothing installed. For exporting Gmail it is the best free option if you're comfortable pasting code and want something a browser extension can't do, such as a scheduled daily sync.
This page gives you a complete script that handles the parts most snippets skip: paging, the 6-minute limit, resuming after a timeout, duplicate rows and subjects that Sheets mistakes for formulas. After that it covers the quotas, the permission you grant, and an honest comparison with Gmail Exporter and Google Takeout.
The script: Gmail search results to Sheets
It exports every message in the threads that match a Gmail search into a sheet called Gmail export. You get one row per message with these columns: Message ID, Date, From, To, Cc, Subject, the first 300 characters of the plain-text body, the thread's labels, and a link back to Gmail.
Change the QUERY line to any search you'd type in Gmail. Our Gmail search operators cheat sheet lists the ones worth combining, such as from:, label:, after: and has:attachment.
/**
* Export Gmail search results to Google Sheets (resumable).
* Paste into Extensions > Apps Script of a Google Sheet, then run exportGmail.
*/
const QUERY = 'label:invoices before:2026/09/01'; // any Gmail search; a fixed before: date keeps paging stable
const SHEET_NAME = 'Gmail export';
const BATCH = 100; // threads per GmailApp.search() call
const MAX_RUN_MS = 4.5 * 60 * 1000; // stop well before the 6-minute limit
const BODY_CHARS = 300; // characters of plain-text body to keep
const HEADERS = ['Message ID', 'Date', 'From', 'To', 'Cc', 'Subject', 'Body (start)', 'Labels', 'Link'];
function exportGmail() {
const started = Date.now();
const props = PropertiesService.getScriptProperties();
let start = Number(props.getProperty('nextStart') || 0);
const sheet = getSheet_();
const seen = existingIds_(sheet);
while (Date.now() - started < MAX_RUN_MS) {
const threads = GmailApp.search(QUERY, start, BATCH);
if (threads.length === 0) {
finish_(props);
return;
}
append_(sheet, rowsFor_(threads, seen));
start += threads.length;
props.setProperty('nextStart', String(start)); // checkpoint after every batch
}
scheduleNext_(); // out of time: continue in about a minute
}
/** Clears the checkpoint and any pending continuation (run before a new export). */
function resetExport() {
PropertiesService.getScriptProperties().deleteProperty('nextStart');
deleteTriggers_();
}
function rowsFor_(threads, seen) {
const messagesByThread = GmailApp.getMessagesForThreads(threads);
const rows = [];
threads.forEach(function (thread, i) {
const labels = thread.getLabels().map(function (l) { return l.getName(); }).join(', ');
const link = thread.getPermalink();
messagesByThread[i].forEach(function (msg) {
const id = msg.getId();
if (seen.has(id)) return; // skip rows already in the sheet
seen.add(id);
rows.push([
id,
msg.getDate(),
safe_(msg.getFrom()),
safe_(msg.getTo()),
safe_(msg.getCc()),
safe_(msg.getSubject()),
safe_(msg.getPlainBody().replace(/\s+/g, ' ').slice(0, BODY_CHARS)),
safe_(labels),
link
]);
});
});
return rows;
}
function append_(sheet, rows) {
if (!rows.length) return;
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, HEADERS.length).setValues(rows);
}
function getSheet_() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_NAME) || ss.insertSheet(SHEET_NAME);
if (sheet.getLastRow() === 0) sheet.appendRow(HEADERS);
return sheet;
}
function existingIds_(sheet) {
const last = sheet.getLastRow();
if (last < 2) return new Set();
const ids = sheet.getRange(2, 1, last - 1, 1).getValues();
return new Set(ids.map(function (r) { return String(r[0]); }));
}
// Stops Sheets from reading a subject like "=HYPERLINK(...)" as a formula.
function safe_(text) {
const s = String(text || '');
return /^[=+\-@]/.test(s) ? "'" + s : s;
}
function scheduleNext_() {
deleteTriggers_();
ScriptApp.newTrigger('exportGmail').timeBased().after(60 * 1000).create();
}
function finish_(props) {
props.deleteProperty('nextStart');
deleteTriggers_();
}
function deleteTriggers_() {
ScriptApp.getProjectTriggers().forEach(function (t) {
if (t.getHandlerFunction() === 'exportGmail' && t.getEventType() === ScriptApp.EventType.CLOCK) {
ScriptApp.deleteTrigger(t);
}
});
}
We checked the logic in a mocked test with 357 threads and 714 messages, forcing timeouts. The script paged through every batch, resumed from its checkpoint, wrote each message exactly once, and added nothing when run again. That's a logic test, not a test against your mailbox, so start with a narrow search.
What each part does
- Batches of 100 threads. Google's GmailApp reference warns that
search(query)without paging "will fail when the size of all threads is too large". The script always usessearch(query, start, max). - One write per batch. Rows are collected in memory and written with a single
setValues()call, which is far quicker than appending one row at a time. - Checkpoint. After every batch the position is saved in Script Properties. If the run stops, the next run carries on from there.
- Self-continuation. At 4.5 minutes the script stops and schedules itself to run again about a minute later, then deletes that trigger when the export is done.
- De-duplication. Message IDs already in column A are skipped, so re-runs and overlapping searches don't double up.
- Formula safety. A subject starting with
=,+,-or@gets a leading apostrophe so Sheets stores it as text instead of evaluating it.
Two behaviors to know
It exports whole threads. Gmail search returns conversations, so if one message in a thread matches, all messages in that thread are exported. Filter the Date or From column afterwards if you need only the matching ones.
Labels are per thread. getLabels() returns the user-created labels on the thread, not system labels like Inbox or Starred, and every message in the thread shows the same labels.
Step-by-step setup
- Create a sheet. Open a new Google Sheet in the account whose Gmail you want to export.
- Open the editor. Click Extensions → Apps Script. A project bound to that sheet opens.
- Paste the code. Replace the contents of
Code.gswith the script and editQUERY. Save. - Run it. Pick
exportGmailin the function menu and click Run. - Authorize. Google asks you to approve access to Gmail, Sheets and triggers. Because the script isn't a verified app, a Gmail account sees Google's unverified-app screen, even for the account that wrote it (see client verification). Continue only if you trust the code you pasted.
- Watch the sheet fill. Rows arrive in batches. For a large search, the run stops around 4.5 minutes and continues by itself. Check progress under Executions in the editor.
- Start over cleanly. To run a different search, run
resetExportfirst, then changeQUERY.
The limits you'll hit
Google publishes the numbers on its Apps Script quotas page. It notes that quotas are per user, reset 24 hours after the first request, and can change without notice. These are the ones that matter for a Gmail export (checked September 28, 2026):
| Limit | Consumer (gmail.com) | Google Workspace |
|---|---|---|
| Script runtime | 6 min / execution | 6 min / execution |
| Email read/write (excluding send) | 20,000 / day | 50,000 / day |
| Triggers total runtime | 90 min / day | 6 hr / day |
| Triggers per user per script | 20 | 20 |
| Properties read/write | 50,000 / day | 500,000 / day |
| Simultaneous executions per user | 30 | 30 |
What that means in practice
The 6-minute wall. Every execution is cut off at 6 minutes; the error usually reads "Exceeded maximum execution time". Reading message bodies is the slow part; the GmailApp reference itself notes that getPlainBody() "takes longer" than getBody(). That's why the script stops itself at 4.5 minutes and saves its place.
The daily read budget. On a gmail.com account, 20,000 email reads/writes a day caps a single day's work. Google doesn't spell out exactly how each GmailApp call is counted, so treat that as a ceiling. A mailbox with, say, 60,000 messages will take several days of runs. Hitting a daily quota throws an error such as "Service invoked too many times". Wait for the reset and run again; the checkpoint keeps your place.
The trigger budget. Continuation runs are started by triggers, and triggers get 90 minutes a day on a consumer account. At about 4.5 minutes per run, that's roughly 20 automatic continuations a day. Google says the "Service using too much computer time for one day" error most commonly hits scripts run on a trigger. Running exportGmail by hand also works.
Workaround: continuation with triggers
The pattern in the script is the standard one: do a bounded amount of work, save progress in Script Properties, and create a one-off time-driven trigger with after() to pick up the rest. Google's installable triggers guide explains the timing: time-driven triggers can run from every minute to once a month, and the exact time may be slightly randomized.
One detail makes resuming reliable. The script pages by position (thread 0–99, 100–199 and so on). If new mail matching the search arrives mid-export, positions shift. Duplicates are caught by the Message ID check, but a thread that leaves the results (archived out of a label, deleted) could be skipped. Put a fixed before: date in the query, as in the example, so the result set doesn't move while you export.
Optional: a daily sync
This is where Apps Script beats any one-off tool. Add these functions to the same file, run installDailyTrigger once, and every morning new matching emails from the last two days are appended, with duplicates skipped:
// Daily sync: adds new matching emails once a day. Uses the helpers above.
const DAILY_QUERY = 'label:invoices newer_than:2d'; // overlap of 2 days; duplicates are skipped
function dailySync() {
const sheet = getSheet_();
const seen = existingIds_(sheet);
let start = 0;
let threads;
do {
threads = GmailApp.search(DAILY_QUERY, start, BATCH);
append_(sheet, rowsFor_(threads, seen));
start += threads.length;
} while (threads.length === BATCH);
}
/** Run once to schedule dailySync every morning (time zone of the script). */
function installDailyTrigger() {
ScriptApp.newTrigger('dailySync').timeBased().everyDays(1).atHour(7).create();
}
The two-day window overlaps on purpose, so a late run or a missed day doesn't leave gaps. For comparison with paid automation services, see Zapier vs a direct export.
Permissions: what the script's OAuth scope grants
This is the part most tutorials skip. Per Google's reference, GmailApp methods "require authorization with the https://mail.google.com/ scope". That is full access to your Gmail: read, send, and delete. The script above only reads, but the permission you approve is broader than what the code does.
- Read every line before you authorize. A pasted script from a stranger runs with your full Gmail permission.
- The code runs on Google's servers under your account, and the output stays in your own Google Sheet. No third party is involved unless the code sends data somewhere, which the script above doesn't.
- Narrower scopes need different code. Apps Script lets you set scopes explicitly in the manifest's
oauthScopesfield (see authorization scopes). To request only read-only Gmail access you would need to rewrite the script with the advanced Gmail service instead of GmailApp. - Revoke when you're done. You can review and remove apps connected to your Google Account at any time (Google Account help).
If authorization is blocked on a work account, check with your IT admin before looking for a workaround.
Apps Script vs Gmail Exporter vs Takeout
Three free starting points, built for different jobs:
| Apps Script (this page) | Gmail Exporter extension | Google Takeout | |
|---|---|---|---|
| Setup time | 10–20 min (paste, edit, authorize) | Install from Chrome Web Store | A few clicks, then wait for the archive |
| Coding needed | Yes, to change columns or fix errors | No | No |
| Limits | 6 min/run; 20,000 reads/day on gmail.com | Works on the Gmail view, search or label open in your tab | Whole mailbox; minutes to days to prepare |
| Where it runs | Google's servers, under your account | Locally in your browser | Google's servers |
| Permission granted | Full Gmail scope (mail.google.com) | Chrome extension permissions, no cloud OAuth | None beyond your Google sign-in |
| Output | Google Sheet (any columns you code) | CSV free; Excel and JSON with Pro | MBOX archive, not a spreadsheet |
| De-duplication | Only if coded (this script dedupes by message ID) | One click | No |
| Scheduled sync | Yes, with triggers | No, manual exports | Scheduled exports every 2 months for a year |
| Cost | No separate charge; bounded by quotas | Free CSV; Pro, Business, Premium are one-time upgrades | Free |
For the wider field, including paid SaaS tools, see our roundup of the best Gmail export tools and the head-to-head Gmail Exporter vs cloudHQ.
When to choose which
Choose Apps Script when you need a recurring, hands-off feed into a Google Sheet (new invoices every morning, new leads from a form notification) or custom columns such as an order number parsed from the body. You get the most control, and you also own the maintenance.
Choose an extension like Gmail Exporter for a one-off or occasional export: a contact list from a label, a year of receipts, the results of a search. There's no code, no quota errors, and one-click de-duplication. Files are written locally, and CSV imports straight into Sheets. See export Gmail to Google Sheets for that route. The free plan exports email address, subject, body preview and service. Dates, sent/received direction, names and phone numbers from signatures, and Excel/JSON are Pro features.
Choose Takeout when you want a complete backup of the mailbox to keep or import into another mail client, not a spreadsheet.
Not in the mood to debug quotas?
Export the same Gmail search to a Sheets-ready CSV in one click, privately in your browser.
Add to Chrome — It's FreeFrequently asked questions
Is Apps Script free for exporting Gmail?
There's no separate charge to run Apps Script with a Google account. What limits you are the published quotas: 6 minutes per execution and 20,000 email reads/writes per day on a gmail.com account, or 50,000 on Google Workspace.
Can Apps Script export Gmail attachments?
Yes. GmailMessage.getAttachments() returns each attachment as a blob, which a script can save to Google Drive. It makes runs slower and uses Drive storage, so export attachments in a separate, narrow search, for example with has:attachment and a date range.
Can the export run automatically every day?
Yes. Create a time-driven trigger with everyDays(1), as in the daily sync above, and search a short recent window such as newer_than:2d. Trigger runs share a daily runtime budget of 90 minutes on consumer accounts and 6 hours on Workspace.
Why do I get "Exceeded maximum execution time"?
Every Apps Script execution stops at 6 minutes. Reading thousands of message bodies takes longer than that. Process threads in batches, save your position in Script Properties, and continue in a new run, which is what the script on this page does automatically.
Why does the sheet include emails that don't match my search?
Gmail search returns threads, and the script exports every message in each matching thread. If one reply matches, the whole conversation comes along. Filter by date or sender in the sheet, or check each message in code if you need exact matches.