Apps Script's 6-minute execution limit kills large MySQL imports. Learn four fixes: pagination with LIMIT/OFFSET, query optimization, SQL aggregation, and switching to a connector that handles large datasets automatically.
You built a script that pulls MySQL data into Google Sheets, tested it on a small table, and it worked fine. Then you ran it against a real table with 20,000 rows and got this: Exceeded maximum execution time. The script died partway through and left your sheet half-populated. This is the single most cited Apps Script blocker for database integrations, and it has several reliable fixes.
Understanding the Execution Time Limit
Google Apps Script enforces strict execution time limits to prevent runaway scripts from consuming server resources:
- Free Google accounts: 6 minutes per execution
- Google Workspace (paid): 30 minutes per execution
These limits apply to the total clock time of a single script run. Once the timer expires, Google kills the execution immediately: mid-loop, mid-batch, mid-write. Any data already written to the sheet stays; anything not yet processed is lost.
For MySQL imports, the time budget is consumed by three activities: establishing the JDBC connection (typically 1–5 seconds), executing the query and fetching rows from the remote database, and writing rows to the spreadsheet using sheet.appendRow(). The last activity is where most time is lost. Calling appendRow() in a loop is extremely slow because each call is a separate API call to Google's Sheets service. A loop of 5,000 appendRow() calls can consume 4–5 minutes by itself, even if the MySQL query returned results instantly.
Before trying any other fix, replace appendRow() loops with setValues() on a range. This is a single API call regardless of row count and is 10–50x faster:
// Slow - one API call per row
for (var i = 0; i < data.length; i++) {
sheet.appendRow(data[i]);
}
// Fast - one API call for all rows
sheet.getRange(2, 1, data.length, data[0].length).setValues(data);If switching to setValues() solves your timeout, stop here. If you are still timing out, the problem is the MySQL fetch itself, and you need one of the fixes below.
Fix 1: Paginate with LIMIT/OFFSET and Script Properties
Pagination breaks a large import into multiple script runs, each processing a chunk of rows. Script Properties is Apps Script's key-value store that persists between executions. You use it to track how far the import has progressed.
How it works: Each run imports a fixed page of rows (say, 2,000), stores the next offset in Script Properties, and exits. A time-based trigger fires the script again after a short delay. The next run reads the saved offset, fetches the next page, and continues. When all rows are imported, the script deletes the offset key and stops scheduling itself.
Here is a complete working implementation:
var PAGE_SIZE = 2000;
var TRIGGER_INTERVAL_MINUTES = 1;
function startPaginatedImport() {
// Clear previous state
PropertiesService.getScriptProperties().deleteProperty('mysql_offset');
PropertiesService.getScriptProperties().deleteProperty('import_complete');
// Clear the sheet
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Data');
sheet.clearContents();
// Run the first page immediately
importNextPage_();
// Schedule subsequent pages
ScriptApp.newTrigger('importNextPage_')
.timeBased()
.everyMinutes(TRIGGER_INTERVAL_MINUTES)
.create();
}
function importNextPage_() {
var props = PropertiesService.getScriptProperties();
// Check if a previous run marked import as complete
if (props.getProperty('import_complete') === 'true') {
deleteTriggers_('importNextPage_');
return;
}
var offset = parseInt(props.getProperty('mysql_offset') || '0', 10);
var conn = Jdbc.getConnection(
'jdbc:mysql://your-host:3306/your_database',
'your_username',
'your_password'
);
var query = 'SELECT id, name, status, created_at FROM orders ' +
'ORDER BY id ' +
'LIMIT ' + PAGE_SIZE + ' OFFSET ' + offset;
var stmt = conn.createStatement();
var results = stmt.executeQuery(query);
var sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Data');
var rows = [];
// Write headers only on the first page
if (offset === 0) {
var meta = results.getMetaData();
var headers = [];
for (var c = 1; c <= meta.getColumnCount(); c++) {
headers.push(meta.getColumnName(c));
}
sheet.appendRow(headers);
}
var rowCount = 0;
while (results.next()) {
rows.push([
results.getInt(1),
results.getString(2),
results.getString(3),
results.getString(4)
]);
rowCount++;
}
results.close();
stmt.close();
conn.close();
if (rows.length > 0) {
var startRow = sheet.getLastRow() + 1;
sheet.getRange(startRow, 1, rows.length, rows[0].length).setValues(rows);
}
if (rowCount < PAGE_SIZE) {
// This page had fewer rows than PAGE_SIZE - we've reached the end
props.setProperty('import_complete', 'true');
deleteTriggers_('importNextPage_');
Logger.log('Import complete. Total rows: ' + (offset + rowCount));
} else {
// Save offset for the next run
props.setProperty('mysql_offset', String(offset + PAGE_SIZE));
Logger.log('Page complete. Next offset: ' + (offset + PAGE_SIZE));
}
}
function deleteTriggers_(functionName) {
var triggers = ScriptApp.getProjectTriggers();
for (var i = 0; i < triggers.length; i++) {
if (triggers[i].getHandlerFunction() === functionName) {
ScriptApp.deleteTrigger(triggers[i]);
}
}
}Key details about this implementation:
ORDER BY idis critical. Without a consistent sort order, OFFSET can return overlapping or skipped rows if rows are inserted or deleted between pages.- The trigger fires every minute. Adjust
TRIGGER_INTERVAL_MINUTESbased on how long each page takes. - Run
startPaginatedImport()once manually to kick off the process. Subsequent pages run automatically. - Use
deleteTriggers_()to clean up after completion: orphaned triggers continue firing indefinitely.
Fix 2: Optimize the SQL Query
If your query is slow, pagination buys you more time but does not fix the underlying problem. A poorly written query that takes 45 seconds per page will still time out even with pagination. Address the query first.
Add indexes on columns used in WHERE and ORDER BY clauses.
-- If your import query is: SELECT * FROM orders WHERE created_at > '2025-01-01' ORDER BY id -- Add these indexes if they do not exist: CREATE INDEX idx_orders_created_at ON orders (created_at); CREATE INDEX idx_orders_id ON orders (id);
Use EXPLAIN in MySQL to see whether your query is doing a full table scan:
EXPLAIN SELECT * FROM orders WHERE created_at > '2025-01-01' ORDER BY id;
If the type column shows ALL, the query is scanning every row. An index on the relevant column changes this to a range scan and can reduce query time by 10–100x on large tables.
Select only the columns you need. SELECT * fetches every column, including BLOBs, long TEXT fields, and columns you never display in Sheets. Name the columns explicitly:
-- Slow: fetches all columns including large ones you may not need SELECT * FROM customers -- Fast: fetch only what you display SELECT id, first_name, last_name, email, status FROM customers
Filter the date range at the SQL level. Instead of importing all records and filtering in Sheets, push the filter into the query:
SELECT id, name, amount, created_at FROM orders WHERE created_at >= DATE_SUB(NOW(), INTERVAL 90 DAY) ORDER BY created_at DESC
Fix 3: Aggregate in SQL, Not in Sheets
Many Sheets imports fetch raw transactional data and then use Sheets formulas (SUMIF, COUNTIF, pivot tables) to produce summary views. This is the worst pattern for execution time: you are importing millions of rows to compute numbers that MySQL could calculate in milliseconds.
Move the aggregation into SQL:
-- Instead of: importing all 500,000 order rows and summing in Sheets SELECT * FROM orders -- Do: aggregate in MySQL and import the summary SELECT DATE(created_at) AS order_date, status, COUNT(*) AS order_count, SUM(amount) AS total_revenue, AVG(amount) AS avg_order_value FROM orders WHERE created_at >= DATE_SUB(NOW(), INTERVAL 90 DAY) GROUP BY DATE(created_at), status ORDER BY order_date DESC
The second query might return 200 rows instead of 500,000. It runs faster in MySQL (which is optimized for aggregation), transfers far less data across the network, writes a fraction of the rows to Sheets, and finishes well within the execution time limit.
If you need both the summary view and row-level detail, put them in separate Sheets tabs with separate queries: one lightweight aggregate tab that refreshes frequently, and a detail tab that refreshes less often and uses pagination.
Fix 4: Switch to a Connector That Handles Large Datasets
The execution time limit is not a bug you can fully work around. It is a hard constraint of the Apps Script runtime. For datasets that genuinely require large imports on frequent schedules, the correct solution is infrastructure that is not subject to the 6-minute limit.
Third-party connectors like brooked.io run on their own servers. When you configure a sync, the connector's server executes the query, handles pagination internally, and writes results to your spreadsheet via the Sheets API. There is no 6-minute clock. A sync that would time out after 6 minutes in Apps Script runs to completion in the background.
This matters when:
- Your table has more than ~10,000 rows and grows regularly
- You need frequent refreshes (hourly or more)
- The query itself is slow and optimization is not feasible
- You have multiple large tables syncing to the same spreadsheet
The trade-off is cost: third-party connectors are paid services. The break-even point is roughly the time you spend maintaining pagination scripts and debugging timeout failures. For most teams with production data needs, that break-even comes quickly.
Comparison of Approaches
| Approach | Fixes Timeout? | Complexity | Ongoing Maintenance | Cost | Best For |
|---|---|---|---|---|---|
Switch to setValues() | Sometimes | Low | None | Free | Small/medium datasets slow due to appendRow() loop |
| Pagination + triggers | Yes | High | Medium | Free | Large datasets, developer available |
| SQL query optimization | Partially | Medium | Low | Free | Slow queries, full table scans |
| SQL aggregation | Yes (reduces rows) | Medium | Low | Free | Summary/reporting use cases |
| Third-party connector | Yes | Low | None | Paid | Large datasets, no developer, frequent refreshes |
Troubleshooting
Script times out on the first page even with pagination The page size is too large. Reduce PAGE_SIZE from 2,000 to 500 or even 100. Profile the script by adding timestamps at the start and end of the JDBC fetch and the setValues() call to identify which operation is consuming the most time.
Import restarts from row 1 after a timeout The script is calling sheet.clearContents() at the start of every run. Move the clear call into startPaginatedImport() only, not into importNextPage_(). The initial setup function should clear the sheet once; subsequent pages should append to existing data.
Trigger keeps firing after import is complete The deleteTriggers_() function was not called, or it failed silently. Go to Extensions > Apps Script > Triggers and delete any orphaned importNextPage_ triggers manually. Then verify the trigger cleanup logic runs on both the "complete" path and any error paths in your code.
Offset data is stale: rows are duplicated or missing OFFSET-based pagination is sensitive to table mutations (insertions or deletions) that happen during the import. For tables that change frequently, switch to keyset pagination: instead of tracking an offset, track the last imported id value and use WHERE id > last_id ORDER BY id LIMIT page_size. This is immune to row count changes.
"Script Properties quota exceeded" error Apps Script limits script property values to 9KB each and total properties to 500KB per script. If you are storing large amounts of state in Script Properties, trim it down. For pagination, you only need to store a single offset integer, which is well within limits.
Bottom Line
The "exceeded maximum execution time" error in Apps Script is usually caused by one of two things: slow row-by-row writes to Sheets (fix with setValues()), or a result set too large to fetch within the time limit (fix with pagination, SQL optimization, or a connector). For most teams, switching to setValues() and adding basic SQL filtering eliminates the timeout. For large production datasets, pagination with Script Properties or a third-party connector that runs outside the Apps Script runtime is the reliable long-term solution.
Stop Fighting the 6-Minute Limit
brooked.io runs MySQL-to-Sheets syncs on its own infrastructure, no execution time limit, no pagination scripts to maintain. Configure once and let it sync automatically on any schedule.
Related articles:
- Does Google Sheets Have a MySQL Connector?
- How to Write Data from Google Sheets Back to MySQL
- Zapier vs Direct Connector for MySQL to Google Sheets
Troubleshooting quick reference
Frequently asked questions
What is the execution time limit for Google Apps Script?
Free Google accounts have a 6-minute execution time limit per script run. Google Workspace (paid) accounts have a 30-minute limit. These limits apply to total clock time, not CPU time, so network latency and slow database queries count against the limit.
Can I increase the execution time limit?
No. The limits are set by Google and cannot be changed, even on Workspace accounts. The only ways to work around the limit are to complete the work faster (query optimization, setValues() instead of appendRow()), break the work into multiple runs (pagination), or move the work to infrastructure that does not have the limit (a third-party connector or a Cloud Run function).
How many rows can Apps Script import from MySQL before timing out?
It depends on query speed, network latency, and how you write rows to the sheet. With appendRow() in a loop, 2,000–5,000 rows often triggers a timeout. With setValues() on a range, the limit is driven by MySQL fetch time, often 10,000–20,000 rows within 6 minutes on a fast database. With pagination, there is no effective row limit.
Does the 30-minute Workspace limit apply to triggered scripts?
Yes. All Apps Script executions (manual, triggered, and add-on) share the same per-execution time limit. A time-based trigger that fires every 30 minutes does not get 30 minutes of additional execution time; it still gets the standard limit (6 or 30 minutes depending on account type).
Is LIMIT/OFFSET pagination reliable for production imports?
It works but has one important weakness: if rows are inserted or deleted in the source table between pages, the offset can shift, causing rows to be duplicated or skipped. For stable tables (historical data, audit logs), it is reliable. For tables that change frequently during the import window, use keyset pagination with WHERE id > last_id instead.
My query uses a JOIN across three tables and is very slow. Should I create a view?
Yes: a MySQL view is a good solution. Create a view that encapsulates the JOIN logic, then query the view from Apps Script with a simple SELECT FROM my_view WHERE .... This also makes the Apps Script code cleaner. If performance is still poor, create a summary table that is updated by a MySQL scheduled event or trigger, and query that instead.
Ready to connect your data to Google Sheets?
Brooked's free tier covers 100 imports per month with AI Analyst included, no credit card required.
Install Brooked free →

