Pricing

How to Fix "Exceeded Maximum Execution Time" When Pulling MySQL Data into Google Sheets

JDBC URLjdbc:mysql://HOST:3306/DATABASE ?useSSL=true&characterEncoding=UTF-8Apps Script · JDBCconst conn = Jdbc.getConnection(url, user, pass);const rs = conn.createStatement() .executeQuery("SELECT * FROM orders");sheet.appendRow(row); // → Google Sheets5,280rows importedsynced
JW
James Whitfield

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:

javascript
// 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:

javascript
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 id is 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_MINUTES based 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.

sql
-- 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:

sql
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:

sql
-- 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:

sql
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:

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

ApproachFixes Timeout?ComplexityOngoing MaintenanceCostBest For
Switch to setValues()SometimesLowNoneFreeSmall/medium datasets slow due to appendRow() loop
Pagination + triggersYesHighMediumFreeLarge datasets, developer available
SQL query optimizationPartiallyMediumLowFreeSlow queries, full table scans
SQL aggregationYes (reduces rows)MediumLowFreeSummary/reporting use cases
Third-party connectorYesLowNonePaidLarge 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:

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 →

Also in Google Sheets

More Google Sheets guides

Get your spreadsheet hours back

Brooked sets up in seconds. Your team is querying live data before lunch.

Get started free