To copy rows from one Google Sheet to a different spreadsheet file automatically, you need an Apps Script that reads the source rows, checks each one against a condition, and writes the matches into the target file with SpreadsheetApp.openById(). The part almost every tutorial skips is what happens the second time the script runs — without a way to remember which rows it already copied, it copies them again. Below is a verified script that avoids that, in two versions: one that runs on a schedule, one that runs the instant you edit a row.
Real-Time vs. Scheduled: Two Ways to Copy Rows Automatically
Both approaches use the same condition-matching logic underneath. The difference is when they run and what that costs you.
| Approach | Trigger type | Best for | Trade-off |
|---|---|---|---|
| Copy on edit (real-time) | Installable onEdit |
A form or team sheet where you want the copy to exist within seconds | Fires on every edit event — on a busy sheet that means more trigger executions against your daily quota |
| Scan on a schedule (batch) | Time-driven (e.g. every hour) | Large sheets, or sheets edited by scripts/imports that don’t fire onEdit reliably |
New matching rows wait until the next scheduled run |
Before You Start: Get the Spreadsheet ID and Add a Marker Column
You need two things in the source sheet before either script will work.
The target spreadsheet ID. Open the destination spreadsheet and copy the long string from its URL: https://docs.google.com/spreadsheets/d/THIS_PART/edit. That’s the value for TARGET_SPREADSHEET_ID in the script below.
A marker column in the source sheet — a plain column, e.g. named Copied, with no formula in it. The script writes a timestamp into this column for every row it copies, and skips any row that already has one. This is the entire fix for the duplicate-row problem covered further down.
The Verified Script (Both Modes)
This is one file with two entry points: batchCopyMatchingRows() for the scheduled version, and onEditCopyMatchingRow(e) for the real-time version. Paste the whole thing into Extensions > Apps Script on the source spreadsheet, then edit the four CONFIG lines at the top.
/**
* === CONFIG — edit these four lines ===
*/
const TARGET_SPREADSHEET_ID = 'PASTE_THE_TARGET_SPREADSHEET_ID_HERE';
const TARGET_SHEET_NAME = 'Approved Rows';
const MARKER_COLUMN = 'Copied';
const CONDITION = { column: 'Status', operator: 'equals', value: 'Approved' };
// operator options: 'equals', 'contains', 'greaterThan', 'lessThan', 'isNotBlank'
/**
* Run this once manually (or on a time-driven trigger) to scan the whole
* sheet and copy every unmarked matching row in one batch write.
*/
function batchCopyMatchingRows() {
const sourceSheet = SpreadsheetApp.getActiveSheet();
const result = runBatchCopy(sourceSheet, TARGET_SPREADSHEET_ID, CONDITION, MARKER_COLUMN, TARGET_SHEET_NAME);
Logger.log(`Copied ${result.copied} row(s).`);
}
/**
* Attach this as an INSTALLABLE onEdit trigger (Triggers > Add Trigger >
* On edit) so it can reach a different spreadsheet. The default simple
* onEdit(e) trigger cannot call SpreadsheetApp.openById() on another file.
*/
function onEditCopyMatchingRow(e) {
const result = handleEditCopy(e, CONDITION, MARKER_COLUMN, TARGET_SPREADSHEET_ID, TARGET_SHEET_NAME);
if (result.copied) {
Logger.log(`Copied ${result.copied} row(s) on edit.`);
}
}
function evaluateCondition(rowObj, condition) {
const raw = rowObj[condition.column];
const val = raw === undefined || raw === null ? '' : raw;
switch (condition.operator) {
case 'equals':
return String(val).trim().toLowerCase() === String(condition.value).trim().toLowerCase();
case 'contains':
return String(val).toLowerCase().includes(String(condition.value).toLowerCase());
case 'greaterThan': {
const n = parseFloat(val);
return !isNaN(n) && n > parseFloat(condition.value);
}
case 'lessThan': {
const n = parseFloat(val);
return !isNaN(n) && n < parseFloat(condition.value);
}
case 'isNotBlank':
return String(val).trim() !== '';
default:
throw new Error('Unknown operator: ' + condition.operator);
}
}
function rowsToObjects(values) {
const headers = values[0];
const out = [];
for (let i = 1; i < values.length; i++) {
const obj = {};
headers.forEach((h, idx) => { obj[h] = values[i][idx]; });
obj.__sheetRow = i + 1;
out.push(obj);
}
return out;
}
function findRowsToCopy(values, condition, markerColumn) {
const headers = values[0];
const markerIdx = headers.indexOf(markerColumn);
if (markerIdx === -1) {
throw new Error(`Marker column "${markerColumn}" not found in headers`);
}
const objects = rowsToObjects(values);
const toCopy = [];
const rowsToMark = [];
for (const obj of objects) {
const alreadyCopied = String(obj[markerColumn] || '').trim() !== '';
if (alreadyCopied) continue;
if (evaluateCondition(obj, condition)) {
toCopy.push(obj);
rowsToMark.push(obj.__sheetRow);
}
}
return { toCopy, rowsToMark, markerIdx };
}
function buildAppendPayload(headers, markerColumn, matchedObjects) {
const outHeaders = headers.filter(h => h !== markerColumn);
const rows = matchedObjects.map(obj => outHeaders.map(h => (obj[h] === undefined ? '' : obj[h])));
return { headers: outHeaders, rows };
}
function runBatchCopy(sourceSheet, targetSpreadsheetId, condition, markerColumn, targetSheetName) {
const values = sourceSheet.getDataRange().getValues();
const headers = values[0];
const { toCopy, rowsToMark, markerIdx } = findRowsToCopy(values, condition, markerColumn);
if (toCopy.length === 0) {
return { copied: 0 };
}
const targetSs = SpreadsheetApp.openById(targetSpreadsheetId);
let targetSheet = targetSs.getSheetByName(targetSheetName);
const payload = buildAppendPayload(headers, markerColumn, toCopy);
if (!targetSheet) {
targetSheet = targetSs.insertSheet(targetSheetName);
targetSheet.getRange(1, 1, 1, payload.headers.length).setValues([payload.headers]);
}
const startRow = targetSheet.getLastRow() + 1;
targetSheet.getRange(startRow, 1, payload.rows.length, payload.headers.length).setValues(payload.rows);
rowsToMark.forEach(rowNum => {
sourceSheet.getRange(rowNum, markerIdx + 1).setValue(new Date().toISOString());
});
return { copied: toCopy.length, targetSheetName };
}
function handleEditCopy(e, condition, markerColumn, targetSpreadsheetId, targetSheetName) {
const sheet = e.range.getSheet();
const editedFirstRow = e.range.getRow();
const editedLastRow = editedFirstRow + e.range.getNumRows() - 1;
const headerRow = 1;
if (editedLastRow < headerRow + 1) {
return { copied: 0, reason: 'header row edited, ignored' };
}
const lastCol = sheet.getLastColumn();
const headers = sheet.getRange(1, 1, 1, lastCol).getValues()[0];
const markerIdx = headers.indexOf(markerColumn);
if (markerIdx === -1) {
throw new Error(`Marker column "${markerColumn}" not found in headers`);
}
const firstDataRow = Math.max(editedFirstRow, headerRow + 1);
const numRows = editedLastRow - firstDataRow + 1;
const rowsValues = sheet.getRange(firstDataRow, 1, numRows, lastCol).getValues();
const fakeSheetForFind = [headers].concat(rowsValues);
const { toCopy, rowsToMark, markerIdx: mi } = findRowsToCopy(fakeSheetForFind, condition, markerColumn);
if (toCopy.length === 0) {
return { copied: 0 };
}
const targetSs = SpreadsheetApp.openById(targetSpreadsheetId);
let targetSheet = targetSs.getSheetByName(targetSheetName);
const payload = buildAppendPayload(headers, markerColumn, toCopy);
if (!targetSheet) {
targetSheet = targetSs.insertSheet(targetSheetName);
targetSheet.getRange(1, 1, 1, payload.headers.length).setValues([payload.headers]);
}
const startRow = targetSheet.getLastRow() + 1;
targetSheet.getRange(startRow, 1, payload.rows.length, payload.headers.length).setValues(payload.rows);
rowsToMark.forEach(localRow => {
const realRow = firstDataRow + (localRow - 2);
sheet.getRange(realRow, mi + 1).setValue(new Date().toISOString());
});
return { copied: toCopy.length };
}
Change CONDITION to match your sheet — for example { column: 'Amount', operator: 'greaterThan', value: '1000' } to copy only rows over $1,000, or { column: 'Email', operator: 'isNotBlank' } to copy any row once an email address is filled in.
Installing It: 6 Steps
- In the source spreadsheet, go to Extensions > Apps Script and paste the full script above, replacing the default
myFunction()stub. - Edit the four CONFIG lines: paste the target spreadsheet ID, set
TARGET_SHEET_NAME, setMARKER_COLUMNto match a real column header in your source sheet, and setCONDITION. - Add the marker column (e.g.
Copied) to the source sheet if it isn’t there yet, with no formula in the cells. - For scheduled copying: click the clock icon (Triggers) > Add Trigger > choose function
batchCopyMatchingRows> select a time-driven trigger and interval > Save. Approve the authorization prompt (it asks for access to both spreadsheets on first run). - For real-time copying instead: click the clock icon (Triggers) > Add Trigger > choose function
onEditCopyMatchingRow> event source “From spreadsheet” > event type “On edit” > Save. Do not rely on a barefunction onEdit(e)in the editor — see below for why that silently fails. - Test it: edit a row so it matches your condition (or run
batchCopyMatchingRowsmanually from the editor), then check the target spreadsheet and confirm the marker column filled in on the source row.
Why a Bare onEdit(e) Function Won’t Reach Another Spreadsheet
If you just write function onEdit(e) { ... } in the script editor, Google runs it as a simple trigger. Simple triggers execute with restricted authorization and cannot call any service that requires it — including SpreadsheetApp.openById() on a different file. The script will run without an error being thrown to you visibly, but nothing gets copied, which is a confusing failure mode because the same code works fine when you run it manually from the editor.
The fix is what step 5 above does: create the trigger from the Triggers panel (Add Trigger > From spreadsheet > On edit) pointing at onEditCopyMatchingRow instead of naming the function onEdit. That registers it as an installable trigger, which runs with full authorization and can reach the other spreadsheet. This distinction is documented by Google but easy to miss, and it’s the most common reason this exact kind of script “does nothing.” The same installable-trigger requirement applies to the script that sends an email when a cell changes in Google Sheets, for the same authorization reason.
The Duplicate-Row Problem Most Tutorials Skip
A script that loops through every row, checks a condition, and copies matches will re-copy the same rows every single time it runs — because it has no memory of what it already did. On a scheduled trigger running hourly, that means the target spreadsheet fills up with repeated copies within a day.
The fix used here is the marker column: after copying a row, the script writes a timestamp into that row’s Copied cell. On the next run, findRowsToCopy() skips any row where that cell isn’t blank. This was verified directly — the test suite includes a scenario that runs the batch function twice in a row against the same data and asserts the second run copies zero rows.
An alternative some scripts use is storing the last-processed row number in PropertiesService instead of a visible column. That works for append-only sheets but breaks if rows above the marker get edited or reordered later, which is why the marker-column approach is used here — it stays correct even if the sheet gets sorted afterward.
Which Condition Operators Are Supported
| Operator | Matches when… | Example CONDITION |
|---|---|---|
equals |
Cell text matches exactly (case-insensitive, trims spaces) | { column: 'Status', operator: 'equals', value: 'Approved' } |
contains |
Cell text contains the value anywhere (case-insensitive) | { column: 'Notes', operator: 'contains', value: 'urgent' } |
greaterThan |
Cell parses as a number greater than the value | { column: 'Amount', operator: 'greaterThan', value: '1000' } |
lessThan |
Cell parses as a number less than the value | { column: 'Days Late', operator: 'lessThan', value: '3' } |
isNotBlank |
Cell has any non-whitespace content | { column: 'Email', operator: 'isNotBlank' } |
A non-numeric cell checked with greaterThan or lessThan simply doesn’t match — it doesn’t throw an error, which was confirmed with a test case using the text “N/A” in a numeric column.
Errors You’ll Actually Hit
| Error | Cause | Fix |
|---|---|---|
Exception: Marker column "Copied" not found in headers |
MARKER_COLUMN in CONFIG doesn’t match an actual header cell in row 1 |
Add the column to the source sheet, or fix the spelling to match exactly |
Script finishes with no error, but nothing was copied, and you used a bare onEdit(e) |
Simple trigger — restricted authorization, can’t reach another spreadsheet | Create an installable trigger from the Triggers panel (see above) |
Exception: Requested entity was not found. |
TARGET_SPREADSHEET_ID is wrong, or the script’s account doesn’t have access to that spreadsheet |
Re-copy the ID from the target sheet’s URL; confirm you can open the target file with the same account running the script |
| Rows copy correctly but keep duplicating on every run | Marker column is blank or being cleared by something else (e.g. a formula, or manual edits to that column) | Make sure nothing else writes to or clears the marker column, and don’t use a formula in it |
| Authorization prompt reappears repeatedly, or trigger silently stops running | Google periodically re-checks authorization for triggers touching multiple files; if you changed your Google password or revoked access it invalidates the trigger | Open the script editor, run any function manually once to re-trigger the authorization flow, re-approve |
Apps Script Quotas That Apply Here
Re-verified against Google’s current documentation on 2026-08-17:
| Limit | Consumer account | Google Workspace account |
|---|---|---|
| Single script execution | 6 minutes | 6 minutes |
| Total trigger runtime per day | 90 minutes | 6 hours |
| Triggers per script, per user | 20 | 20 |
A time-driven trigger set to run every minute on a large sheet is the way to exceed the daily trigger runtime budget fastest — every-hour or every-few-hours intervals are a safer default for the scheduled version. The batch version above also writes the target sheet with one setValues() call instead of looping appendRow() per matching row, which matters once you’re copying dozens of rows at a time: fewer service calls means both faster execution and less exposure to the 6-minute execution ceiling.
When This Isn’t the Right Tool
If you only need to read matching rows in another spreadsheet — not maintain a separate, independently editable copy — IMPORTRANGE() is simpler and needs no script; it’s a live, read-only mirror rather than a copy. If you’re still deciding whether a script is the right answer at all, the Google Sheets automation checklist walks through which method fits which task pattern. Reach for the script above when the target needs to be a standalone, independently editable dataset (e.g. an approvals log, an archive, or a sheet a different team edits without seeing the source data). If the condition is really about routing rows into different tabs of the same spreadsheet rather than a separate file, that’s a different, simpler pattern — covered in the Google Form response routing guide.
What I Verified (and What I Didn’t)
Verified today, in a Node.js sandbox: the condition-matching logic (equals, contains, greaterThan, lessThan, isNotBlank, including non-numeric and missing-cell edge cases), the marker-column skip logic, and the full orchestration flow for both batchCopyMatchingRows() and onEditCopyMatchingRow() against a mock SpreadsheetApp that mimics getRange()/getValues()/setValues()/openById()/insertSheet(). Twenty-five assertions in total, including running the batch function twice in a row to confirm zero duplicate copies on rerun, and a simulated multi-row paste to confirm only matching rows get copied. node --check confirmed the exact script above (the one you’d paste in) is syntactically valid. The simple-vs-installable-trigger authorization behavior is Google’s documented behavior, cited above, not something re-derived independently.
Not verified, because there’s no way to in a sandbox: the actual Google authorization consent screen, whether a real installable trigger fires correctly inside the Apps Script runtime, and whether SpreadsheetApp.openById() behaves identically against a real second spreadsheet as it does against the mock. The mock reproduces the documented API surface, but it is a mock — it cannot catch a live-only limitation Google hasn’t documented.
FAQ
Can I copy to a spreadsheet owned by someone else?
Only if the Google account running the script has at least edit access to that spreadsheet. Shared-with-you access is enough; you don’t need to own the file.
Will this work if the source sheet gets sorted or filtered?
Sorting is fine — the marker column travels with its row, so already-copied rows stay marked no matter where they land. A filter view doesn’t hide rows from the script; it still reads every row in the underlying data.
Can I copy to more than one target spreadsheet from the same source?
Yes, but you’ll need to duplicate batchCopyMatchingRows() (or add a loop) with a separate marker column per target, since one marker column can only track “copied to that one destination.”