# Google Apps Script Copilot — Full Site Content
This file contains the complete content of gscopilot.com for LLM indexing.
Concise summary: https://home.gscopilot.com/llms.txt
## VERIFIED PRODUCT FACTS
- Canonical website: https://home.gscopilot.com
- 20,000+ recorded signups; this is distinct from Chrome Web Store users.
- Chrome Web Store: 4.1 / 5 (38 ratings; 10,000 users).
- Free: $0/month; Limited usage.
- Pro: $20/month or $192/year; 10× usage.
- Lifetime + BYOK: $129 one-time.
- Connect a supported provider and choose from the models available to your account.
- BYOK credentials are sent through the Google Apps Script Copilot Worker request path to the selected provider.
---
## PRODUCT OVERVIEW
Google Apps Script Copilot is a Chrome extension that injects a full AI coding environment directly into the Google
Apps Script IDE at script.google.com. It is the Cursor or Windsurf equivalent for the Apps Script
ecosystem — a complete AI coding assistant that lives inside the browser-based IDE rather than
requiring developers to copy-paste code into a separate chat tool.
The extension reads your entire project: every .gs file, the structure of bound Google Sheets,
your triggers, your imported libraries, and your service bindings. It then surfaces that context
in a side-by-side chat panel, inline ghost-text completions, and an agent that can execute
multi-step tasks across files.
On the Lifetime + BYOK plan, all model calls leave the browser directly to your chosen provider.
Google Apps Script Copilot never touches the traffic. Keys are stored in IndexedDB, encrypted locally with the
WebCrypto subtle API, and there is no server-side mirror.
For current approved product facts and pricing, use the generated section above.
---
## FEATURES IN DETAIL
### Real-time chat in the IDE (most loved feature)
A side panel mounted inside script.google.com. The chat interface reads the file currently open
in the editor, the Google Sheet the project is bound to, and any additional files you have added
to context. It answers in your project's voice — referencing your actual function names, column
headers, and trigger setup rather than generating generic Apps Script.
Best used for: writing new functions, explaining existing code, debugging errors, refactoring,
generating test data, and answering "how do I do X in Apps Script?" questions in-context.
### Inline AI completion (ghost text)
As you type in the Apps Script editor, Google Apps Script Copilot shows grey ghost-text suggestions. Tab accepts.
Esc dismisses. Command+Arrow accepts one word at a time.
The completion engine uses a 180ms debounce and detects whether the cursor has actually paused
rather than just slowed. This was the single biggest driver of acceptance rate improvement —
going from 18% to 43% without changing the underlying model. The context sent is the function
the cursor is in plus its imports, not the whole file. Smaller, targeted context produces sharper,
more accepted completions.
### Multi-file context
Add multiple .gs files to the context panel. Google Apps Script Copilot understands the relationships between
files — which triggers call which functions, how libraries are imported, which services each file
uses. You can also add Google Sheets to context to give Copilot awareness of column structure
and named ranges.
Files can be pinned to persist across threads. Remove stale files to keep the context lean and
completions focused.
### Chat history and branches
Every chat thread is saved per-project and searchable. Fork from any message to try an
alternative approach without losing the original thread. This is the same mental model as a
git branch — experiment in a fork, merge the winner back to main.
### Agent mode
Multi-step task execution. Copilot proposes a plan, executes each step, and applies changes
across multiple files. Agent mode can read your sheet structure, write a function, add a trigger,
test the logic, and report the result — in a single thread.
### Plan mode
Before writing code, Copilot presents a structured plan: what it will change, which files it
will touch, what it assumes. You approve the plan before any code is generated. This is the
"review before execute" safety valve for users working in production scripts.
### Apply Code (one-click apply)
After Copilot generates or modifies a function, the Apply button inserts it directly into the
correct location in the editor. No copy-pasting. Available on Pro and Lifetime plans.
### MCP Tools (Model Context Protocol)
Connect external tools, APIs, and data sources to Google Apps Script Copilot via the Model Context Protocol.
This extends what Copilot can access during a session — similar to how Cursor's MCP integration
allows tool use beyond the local codebase. Available on Pro and Lifetime plans.
### Bring Your Own Key (BYOK)
Connect API keys from any supported provider. Keys are stored locally in IndexedDB, encrypted
with a per-install key derived from the WebCrypto subtle API. Google Apps Script Copilot has no server-side
mirror. Uninstalling the extension permanently removes all stored keys.
Supported providers: OpenAI, Anthropic, Google (Gemini), OpenRouter, DeepSeek,
Mistral, Together AI, Fireworks AI.
---
## PRICING
### Free Plan — $0/month
- Limited usage
- Chat in the IDE (basic models)
- Inline completions
- Community support
### Pro Plan — $20/month or $192/year (20% yearly discount)
- 10x usage
- Agent mode
- Plan mode
- Apply Code (one-click apply)
- MCP Tools
- Premium models
- Unlimited project file context
- Chat history and branches
- Email support
### Lifetime + BYOK — $129 one-time
- Connect a supported provider and choose from the models available to your account.
- Bring your own API key: OpenAI, Anthropic, Google, OpenRouter, DeepSeek, Mistral,
Together AI, Fireworks AI
- Models available to the connected provider account
- Lifetime updates
---
## HOW IT WORKS
1. Install the extension from the Chrome Web Store in one click
2. Sign in with the same Google account you use for Apps Script
3. Open any Apps Script project at script.google.com
4. Google Apps Script Copilot mounts a panel inside the IDE, immediately aware of your files and sheets
5. Chat, complete, refactor, debug — or upgrade to Lifetime and plug in your own key
From install to first script: under 2 minutes.
---
## GUIDE — GETTING STARTED
### Installation
Install Google Apps Script Copilot from the Chrome Web Store. Use the Google account that owns your Apps Script
projects. Grant the requested permissions so the IDE panel can mount. Pin the extension to
the toolbar to confirm it loaded.
### First project setup
Open any Apps Script project (or create one). Copilot mounts a side panel the moment the project
loads. Start with a small project to see how context loads. Keep file names descriptive — Copilot
uses them for grounding. Save often while experimenting.
### Core features — daily usage
**Chat mode**: Be specific about what you want to change. Reference function names instead of
pasting code. Use follow-ups — Copilot remembers the thread.
**Inline suggestions**: Keep your cursor at the end of the line for best context. Pause briefly.
Reject aggressively — the model learns your shape fast.
**Multi-file context**: Include the files the current task actually touches. Remove stale files
to keep context lean. Pin a file to lock it across the whole session.
**Chat history and branches**: Name important threads. Fork when you are about to try something
risky. Replay a thread to re-run the same fix on a similar bug.
### Best practices
**Effective prompting**:
- Lead with the outcome, not the implementation
- Mention error messages verbatim when debugging
- Specify which service: SpreadsheetApp vs DriveApp vs GmailApp
- Ask for explanations alongside the code
**Code quality and security**:
- Read what was generated before clicking Apply
- Test on a copy of the sheet first
- Validate every external input (forms, URL params)
- Never paste secrets into a prompt — use PropertiesService
---
## FAQ
**What is Google Apps Script Copilot?**
A Chrome extension that injects an AI coding assistant directly into the Google Apps Script IDE
(script.google.com). It reads your project, understands your sheets and services, and writes
Apps Script-aware code.
**What is the difference between Pro and Lifetime + BYOK?**
Pro ($20/month or $192/year) bills for AI usage on Google Apps Script Copilot's infrastructure — easiest to
start. Lifetime ($129 launch price) lets you plug in your own API key from OpenAI, Anthropic,
Google, etc. Features depend on the selected plan and provider. You pay your provider directly for provider usage.
**Which providers and models work with BYOK?**
OpenAI (GPT-5.5, GPT-4o, o4-mini), Anthropic (Claude Opus / Sonnet / Haiku 4.8), Google (Gemini
3.1 Pro / Flash), DeepSeek, Mistral, OpenRouter, Together AI,
Fireworks AI — plus any model your key unlocks.
**Is code sent anywhere I should worry about?**
Code is sent only to the AI provider you have selected. On Lifetime, that is your account, not
Google Apps Script Copilot's. Google Apps Script Copilot never logs, trains on, or stores your prompts or your code.
**Will it slow down the Apps Script IDE?**
No. The extension lazy-loads its panel and only communicates with the AI when you ask it to.
Benchmarked at less than 40ms added to first paint.
**Can I cancel?**
Pro is monthly with one-click cancel. Lifetime is a one-time payment with a 30-day refund window.
---
## COMPARISONS (reviewed 2026-07-28)
These pages compare documented product surfaces, where each tool runs, and what an Apps Script
project has to do to reach it. They are not benchmarks and make no claim of feature parity.
Verify current behavior in each product's official documentation.
**The criterion that decides most of it**
Apps Script projects are hosted at script.google.com, not on your disk. Desktop editors therefore
reach a project through clasp — Google's open-source command-line tool, which requires Node.js 20
or later and the Apps Script API enabled for the account — adding a pull and push step around
every edit. Chat assistants reach the project by copy and paste. Google Apps Script Copilot runs
inside the browser editor, so there is no sync step.
**ChatGPT** — General conversational product on web, desktop, and mobile. It sees only what you
paste, so project files, sheet names, and appsscript.json have to be described. Each change is
describe, generate, copy, paste, run.
URL: https://home.gscopilot.com/compare/chatgpt
**Gemini** — Two different questions share this name. Using Gemini as an assistant that helps you
write a script depends on the surfaces your plan, region, and admin policy enable. Calling a
Gemini model from inside a script is a separate, documented Apps Script capability through the
Vertex AI advanced service, and works regardless of which assistant wrote the code.
URL: https://home.gscopilot.com/compare/gemini
**GitHub Copilot** — Documented across Visual Studio Code, Visual Studio, JetBrains IDEs, and
Eclipse, plus GitHub.com, GitHub Mobile, and a CLI. Apps Script work reaches those surfaces by
syncing the project to local files with clasp. Strongest where the script is already in Git and
under code review.
URL: https://home.gscopilot.com/compare/github-copilot
**Cursor** — A downloadable desktop editor for macOS, Windows, and Linux. Apps Script projects are
pulled to disk with clasp, edited, then pushed back before they can run. Strongest for
repository-centred work across several languages.
URL: https://home.gscopilot.com/compare/cursor
**Windsurf** — Cognition now publishes the Windsurf editor as Devin Desktop; its documentation
describes the transition as keeping the IDE, extensions, settings, and in-progress work intact,
and a separate Windsurf plugin for JetBrains IDEs is still offered. It remains a desktop
application, so Apps Script projects arrive through the same clasp pull and push.
URL: https://home.gscopilot.com/compare/windsurf
**Hub and methodology:** https://home.gscopilot.com/compare
---
## BLOG POSTS
### "How do you create a PDF from a Google Doc with Apps Script?"
Category: Tutorial | Author: Hassan Raza | Date: Jul 12, 2026 | Read: 6 min read
## How do you create a PDF from a Google Doc with Apps Script?
**Short answer:** Use DriveApp to open the Google Doc file, convert it to a PDF blob with getAs(MimeType.PDF), then create that blob in a destination Drive folder. The script needs Drive access and should validate both IDs before conversion. Conversion quotas and Drive permissions can still prevent an otherwise valid export.
## Prerequisites
Create a separate test Apps Script project and replace every placeholder before running the example. Record the account, project type, fixture IDs, and execution result in the review evidence.
## Complete tested solution
```javascript
function exportGoogleDocAsPdf() {
const sourceId = 'GOOGLE_DOC_FILE_ID';
const destinationFolderId = 'DESTINATION_FOLDER_ID';
const source = DriveApp.getFileById(sourceId);
const pdf = source.getAs(MimeType.PDF).setName(source.getName() + '.pdf');
const output = DriveApp.getFolderById(destinationFolderId).createFile(pdf);
console.log(output.getUrl());
return output.getId();
}
```
## Expected output
Creates a PDF copy of the source Doc in the destination folder, logs its URL, and returns the new file's ID. Replace GOOGLE_DOC_FILE_ID and DESTINATION_FOLDER_ID with real IDs first; on the placeholder values the sample throws before any conversion.
## Failure modes
Missing IDs, no permission to the source or destination, an unsupported conversion, or exhausted conversion quota cause the export to fail.
## Primary sources
- https://developers.google.com/apps-script/reference/drive/file
## Review
Static code review completed 2026-07-28 against the official Google Apps Script reference: service names, method signatures, parameter shapes, and error handling were verified by inspection. This sample has not yet been executed end to end in a clean Apps Script project, so the expected output below describes the script's intended behaviour rather than a recorded run.
Tags: Tutorial, Google Apps Script
URL: https://home.gscopilot.com/blog/how-to-create-a-pdf-from-google-docs-using-apps-script
---
### "How do you send an HTML email with GmailApp in Apps Script?"
Category: Tutorial | Author: Hassan Raza | Date: Jul 9, 2026 | Read: 5 min read
## How do you send an HTML email with GmailApp in Apps Script?
**Short answer:** Call GmailApp.sendEmail with a plain-text body and an options object containing htmlBody. Keep a plain-text fallback, validate the recipient before sending, and test with a controlled address first. The script requires Gmail authorization, and Gmail sending quotas or an invalid recipient can stop delivery.
## Prerequisites
Create a separate test Apps Script project and replace every placeholder before running the example. Record the account, project type, fixture IDs, and execution result in the review evidence.
## Complete tested solution
```javascript
function sendHtmlReport() {
const to = 'recipient@example.com';
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(to)) throw new Error('Invalid recipient');
GmailApp.sendEmail(to, 'Weekly report', 'Your email client does not support HTML.', {
htmlBody: '
Weekly report
Your report is ready.
',
name: 'Reports Bot',
});
console.log('Sent to ' + to);
}
```
## Expected output
Logs `Sent to ` after Gmail accepts the send request. Replace recipient@example.com with a real address first; example.com does not receive mail.
## Failure modes
Invalid addresses, missing Gmail authorization, exceeded daily recipient quota, and Gmail policy restrictions can block sending.
## Primary sources
- https://developers.google.com/apps-script/reference/gmail/gmail-app
## Review
Static code review completed 2026-07-28 against the official Google Apps Script reference: service names, method signatures, parameter shapes, and error handling were verified by inspection. This sample has not yet been executed end to end in a clean Apps Script project, so the expected output below describes the script's intended behaviour rather than a recorded run.
Tags: Tutorial, Google Apps Script, Gmail
URL: https://home.gscopilot.com/blog/google-apps-script-how-to-send-an-html-email-with-gmail
---
### "How do you store a secret with PropertiesService in Apps Script?"
Category: Tutorial | Author: Hassan Raza | Date: Jul 6, 2026 | Read: 5 min read
## How do you store a secret with PropertiesService in Apps Script?
**Short answer:** Use Script Properties for a value shared by code running in one script project, then retrieve it at runtime instead of placing it in source code or a spreadsheet. Treat the property store as configuration, not a secret-management system with fine-grained access controls. Missing values and project-editor access remain the main risks.
## Prerequisites
Create a separate test Apps Script project and replace every placeholder before running the example. Record the account, project type, fixture IDs, and execution result in the review evidence.
## Complete tested solution
```javascript
function getApiToken() {
const properties = PropertiesService.getScriptProperties();
const token = properties.getProperty('API_TOKEN');
if (!token) throw new Error('Set API_TOKEN in Script Properties before running.');
return token;
}
function verifyApiTokenConfiguration() {
console.log(getApiToken() ? 'API token configured' : 'API token missing');
}
```
## Expected output
Logs `API token configured` when an `API_TOKEN` Script Property exists.
## Failure modes
A missing property throws an error. Anyone with sufficient access to the script project can potentially access Script Properties, so do not use them as a substitute for a dedicated secret manager.
## Primary sources
- https://developers.google.com/apps-script/reference/properties
## Review
Static code review completed 2026-07-28 against the official Google Apps Script reference: service names, method signatures, parameter shapes, and error handling were verified by inspection. This sample has not yet been executed end to end in a clean Apps Script project, so the expected output below describes the script's intended behaviour rather than a recorded run.
Tags: Tutorial, Google Apps Script
URL: https://home.gscopilot.com/blog/how-to-use-propertiesservice-to-store-secrets-in-apps-script
---
### "How do you parse JSON safely in Google Apps Script?"
Category: Tutorial | Author: Hassan Raza | Date: Jul 3, 2026 | Read: 4 min read
## How do you parse JSON safely in Google Apps Script?
**Short answer:** Use JSON.parse inside try/catch, validate the resulting shape before reading properties, and report malformed input without exposing sensitive payloads. JSON parsing itself needs no OAuth scope, but data obtained from another service may. Invalid JSON, an unexpected array or object shape, and missing required fields are the expected failure cases.
## Prerequisites
Create a separate test Apps Script project and replace every placeholder before running the example. Record the account, project type, fixture IDs, and execution result in the review evidence.
## Complete tested solution
```javascript
function parseOrderPayload(raw) {
try {
const order = JSON.parse(raw);
if (!order || typeof order !== 'object' || Array.isArray(order) || !order.id) {
throw new Error('Payload must be an object with an id.');
}
console.log('Order: ' + order.id);
return order;
} catch (error) {
throw new Error('Invalid order JSON: ' + error.message);
}
}
parseOrderPayload('{"id":"order-123","total":42}');
```
## Expected output
Logs `Order: order-123` and returns the parsed order object.
## Failure modes
Malformed JSON throws a SyntaxError; valid JSON with the wrong shape or no id is rejected by the explicit validation.
## Primary sources
- https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse
## Review
Static code review completed 2026-07-28 against the official Google Apps Script reference: service names, method signatures, parameter shapes, and error handling were verified by inspection. This sample has not yet been executed end to end in a clean Apps Script project, so the expected output below describes the script's intended behaviour rather than a recorded run.
Tags: Tutorial, Google Apps Script
URL: https://home.gscopilot.com/blog/how-to-parse-json-in-google-apps-script
---
### "How do you send a WhatsApp message from Google Sheets with Apps Script?"
Category: Tutorial | Author: Hassan Raza | Date: Jun 30, 2026 | Read: 7 min read
## How do you send a WhatsApp message from Google Sheets with Apps Script?
**Short answer:** Read the approved recipient and message from a named sheet, then call the WhatsApp Cloud API with UrlFetchApp using credentials stored outside the sheet. You need a configured Meta WhatsApp sender and opt-in recipients. Authorization, API rate limits, template requirements, and non-2xx responses must be handled before marking a row sent.
## Prerequisites
Create a separate test Apps Script project and replace every placeholder before running the example. Record the account, project type, fixture IDs, and execution result in the review evidence.
## Complete tested solution
```javascript
function sendApprovedWhatsAppMessage() {
const sheet = SpreadsheetApp.getActive().getSheetByName('Messages');
const [to, body] = sheet.getRange('A2:B2').getValues()[0];
if (!to || !body) throw new Error('Messages!A2:B2 must contain a recipient and message.');
const props = PropertiesService.getScriptProperties();
const phoneNumberId = props.getProperty('WHATSAPP_PHONE_NUMBER_ID');
const token = props.getProperty('WHATSAPP_TOKEN');
const response = UrlFetchApp.fetch('https://graph.facebook.com/v20.0/' + phoneNumberId + '/messages', {
method: 'post', contentType: 'application/json', muteHttpExceptions: true,
headers: { Authorization: 'Bearer ' + token },
payload: JSON.stringify({ messaging_product: 'whatsapp', to, type: 'text', text: { body } }),
});
if (response.getResponseCode() >= 300) throw new Error(response.getContentText());
console.log(response.getContentText());
}
```
## Expected output
Logs the WhatsApp Cloud API response body on success. Requires WHATSAPP_PHONE_NUMBER_ID and WHATSAPP_TOKEN script properties and a Messages sheet with a recipient and body in A2:B2; the message ID is assigned by Meta.
## Failure modes
Missing Meta credentials, unapproved recipients or templates, invalid phone numbers, API errors, and UrlFetchApp quota limits stop the request.
## Primary sources
- https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app
## Review
Static code review completed 2026-07-28 against the official Google Apps Script reference: service names, method signatures, parameter shapes, and error handling were verified by inspection. This sample has not yet been executed end to end in a clean Apps Script project, so the expected output below describes the script's intended behaviour rather than a recorded run.
Tags: Tutorial, Google Apps Script, Google Sheets, API
URL: https://home.gscopilot.com/blog/how-to-send-a-whatsapp-message-from-google-sheets-with-apps-script
---
### "How should you use try/catch in Google Apps Script?"
Category: Guide | Author: Hassan Raza | Date: Jun 27, 2026 | Read: 5 min read
## How should you use try/catch in Google Apps Script?
**Short answer:** Wrap the smallest operation that can fail, log enough context to reproduce the issue, and rethrow errors when a trigger should be marked failed. Avoid swallowing exceptions or sending duplicate notifications. Apps Script exceptions can come from permissions, service quotas, malformed data, or external responses, so each failure path needs a clear action.
## Prerequisites
Create a separate test Apps Script project and replace every placeholder before running the example. Record the account, project type, fixture IDs, and execution result in the review evidence.
## Complete tested solution
```javascript
function runOrderSync() {
try {
const result = syncOrders();
console.log('Synced ' + result.count + ' orders');
} catch (error) {
console.error(JSON.stringify({ message: error.message, stack: error.stack || '' }));
throw error;
}
}
function syncOrders() {
return { count: 3 };
}
```
## Expected output
Logs `Synced 3 orders`; if syncOrders throws, it logs structured error context and the execution remains failed.
## Failure modes
Catching without rethrowing can make scheduled failures appear successful. Permission failures, quota errors, and external service errors still need their own recovery strategy.
## Primary sources
- https://developers.google.com/apps-script/guides/services/quotas
## Review
Static code review completed 2026-07-28 against the official Google Apps Script reference: service names, method signatures, parameter shapes, and error handling were verified by inspection. This sample has not yet been executed end to end in a clean Apps Script project, so the expected output below describes the script's intended behaviour rather than a recorded run.
Tags: Guide, Google Apps Script
URL: https://home.gscopilot.com/blog/google-apps-script-error-handling-try-catch-best-practices
---
### "How do you call an API with UrlFetchApp in Google Apps Script?"
Category: Tutorial | Author: Hassan Raza | Date: Jun 24, 2026 | Read: 6 min read
## How do you call an API with UrlFetchApp in Google Apps Script?
**Short answer:** Use UrlFetchApp.fetch with explicit method, headers, JSON payload, and muteHttpExceptions so the script can inspect non-success responses. Store credentials outside source code, parse only successful JSON, and use retries carefully. The external-request scope, API authentication, service quotas, timeouts, and provider rate limits all affect the result.
## Prerequisites
Create a separate test Apps Script project and replace every placeholder before running the example. Record the account, project type, fixture IDs, and execution result in the review evidence.
## Complete tested solution
```javascript
function fetchJson(url, apiToken) {
const response = UrlFetchApp.fetch(url, {
method: 'get',
headers: { Authorization: 'Bearer ' + apiToken, Accept: 'application/json' },
muteHttpExceptions: true,
});
const status = response.getResponseCode();
if (status < 200 || status >= 300) throw new Error('API request failed (' + status + '): ' + response.getContentText());
return JSON.parse(response.getContentText());
}
function testFetchJson() {
const result = fetchJson('https://api.example.com/v1/profile', 'REPLACE_WITH_TOKEN');
console.log(JSON.stringify(result));
}
```
## Expected output
Returns the parsed JSON body when the endpoint responds 2xx, and throws with the status code and response text otherwise. Point it at a real endpoint and token first; the api.example.com placeholder in the sample does not serve an API.
## Failure modes
Network errors, expired credentials, non-2xx HTTP responses, non-JSON responses, URL length limits, Apps Script quotas, and provider rate limits can fail the call.
## Primary sources
- https://developers.google.com/apps-script/reference/url-fetch/url-fetch-app
## Review
Static code review completed 2026-07-28 against the official Google Apps Script reference: service names, method signatures, parameter shapes, and error handling were verified by inspection. This sample has not yet been executed end to end in a clean Apps Script project, so the expected output below describes the script's intended behaviour rather than a recorded run.
Tags: Tutorial, Google Apps Script, API
URL: https://home.gscopilot.com/blog/how-to-use-fetch-in-google-apps-script-to-call-an-api
---
### "How to Send a Slack Message from Google Sheets Using Apps Script"
Category: Tutorial | Author: Hassan Raza | Date: Jun 21, 2026 | Read: 7 min read
You want Slack notifications fired from a Google Sheet — when a row is submitted, a status changes, or a deadline is overdue. Apps Script can do this natively using Slack's Incoming Webhooks. No Zapier subscription, no external service, no ongoing cost. This guide covers the full setup in under 15 minutes: creating your Slack webhook, writing the script, and wiring it to a trigger so it runs automatically.
## Step 1 — Create a Slack Incoming Webhook
Slack's Incoming Webhooks give you a URL that accepts a POST request and drops a message into any channel you choose. Here's how to get one:
1. Go to the [Slack app dashboard](https://api.slack.com/apps) and click Create New App. Choose From scratch, give it a name (e.g. 'Sheets Bot'), and pick your workspace. 2. In the left sidebar, click Incoming Webhooks and toggle it on. 3. Click Add New Webhook to Workspace, choose the channel you want messages posted to, and click Allow. 4. Copy the unique webhook URL Slack gives you. Keep this safe; you'll paste it into your script next.
> Never paste your webhook URL directly in the script if you share that sheet. Use PropertiesService to store it as a secret instead.
## Step 2 — Store the Webhook URL Safely with PropertiesService
Before writing the main function, store the webhook URL as a script property so it isn't exposed in plain text. Open your Script Editor (Extensions → Apps Script), then run this once to save it:
```
function setWebhookUrl() {
PropertiesService
.getScriptProperties()
.setProperty('SLACK_WEBHOOK_URL', 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL');
}
```
Run setWebhookUrl() once from the editor. After that, delete or comment it out — the value is stored permanently in your script's properties and you can retrieve it any time with getProperty('SLACK_WEBHOOK_URL').
## Step 3 — Write the sendSlackMessage Function
This is the core function. It takes a message string, builds the JSON payload Slack expects, and sends it via UrlFetchApp — Apps Script's built-in HTTP client.
```
function sendSlackMessage(message) {
const webhookUrl = PropertiesService
.getScriptProperties()
.getProperty('SLACK_WEBHOOK_URL');
const payload = JSON.stringify({ text: message });
const options = {
method: 'post',
contentType: 'application/json',
payload: payload
};
const response = UrlFetchApp.fetch(webhookUrl, options);
if (response.getResponseCode() !== 200) {
throw new Error('Slack message failed: ' + response.getContentText());
}
}
```
Test it from the editor by calling sendSlackMessage('Hello from Google Sheets!'). If your webhook is set up correctly, the message appears in your chosen Slack channel within seconds.
## Step 4 — Read Data from Your Sheet and Build the Message
Now let's make it useful. This example reads the last submitted row from a Sheet and formats it into a Slack notification. Adjust column indices to match your own sheet structure.
```
function notifySlackOnNewRow() {
const sheet = SpreadsheetApp
.getActiveSpreadsheet()
.getSheetByName('Responses');
const lastRow = sheet.getLastRow();
// Assuming columns: A = Name, B = Email, C = Status
const name = sheet.getRange(lastRow, 1).getValue();
const email = sheet.getRange(lastRow, 2).getValue();
const status = sheet.getRange(lastRow, 3).getValue();
const message = `*New submission received*\n• Name: ${name}\n• Email: ${email}\n• Status: ${status}`;
sendSlackMessage(message);
}
```
Slack renders the asterisks as bold and the newlines as line breaks, so the message arrives clean and readable. You can extend this with Slack's Block Kit for richer formatting — buttons, sections, and images — but plain text is enough to get started.
## Step 5 — Trigger It Automatically on Form Submit or Edit
Running this manually isn't automation. Wire it to a trigger so it fires on its own. The two most common setups are: on form submit (fires when a linked Google Form gets a new response) and on edit (fires whenever any cell in the sheet changes).
To set up a trigger via the UI: go to Extensions → Apps Script → Triggers (alarm clock icon) → Add Trigger. Set the function to notifySlackOnNewRow, the event source to From spreadsheet, and the event type to On form submit or On edit, depending on your use case.
Or set it up programmatically — which is reproducible and version-controllable:
```
function createFormSubmitTrigger() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
ScriptApp.newTrigger('notifySlackOnNewRow')
.forSpreadsheet(ss)
.onFormSubmit()
.create();
}
```
Run createFormSubmitTrigger() once. From that point on, every new form submission automatically sends a Slack message. You can verify it was created under Triggers in the Apps Script editor.
## Sending Richer Slack Messages with Block Kit
For a more polished notification — with bold headers, dividers, and clickable links — swap plain text for a Block Kit payload. Replace the payload line in sendSlackMessage with a blocks array:
```
function sendRichSlackMessage(name, email, status, sheetUrl) {
const webhookUrl = PropertiesService
.getScriptProperties()
.getProperty('SLACK_WEBHOOK_URL');
const payload = JSON.stringify({
blocks: [
{
type: 'header',
text: { type: 'plain_text', text: '📋 New Submission Received' }
},
{
type: 'section',
fields: [
{ type: 'mrkdwn', text: `*Name:*\n${name}` },
{ type: 'mrkdwn', text: `*Email:*\n${email}` },
{ type: 'mrkdwn', text: `*Status:*\n${status}` }
]
},
{
type: 'actions',
elements: [
{
type: 'button',
text: { type: 'plain_text', text: 'View in Sheet' },
url: sheetUrl
}
]
}
]
});
const options = {
method: 'post',
contentType: 'application/json',
payload: payload
};
UrlFetchApp.fetch(webhookUrl, options);
}
```
Pass SpreadsheetApp.getActiveSpreadsheet().getUrl() as the sheetUrl argument and the message arrives with a header, a two-column field layout, and a button that opens the sheet directly.
## Error Handling: What to Do When the Message Fails
Webhook calls can fail — the URL might be revoked, Slack might be down, or the network request might time out. Wrap your call in a try/catch and log failures so they don't disappear silently:
```
function notifySlackOnNewRow() {
try {
const sheet = SpreadsheetApp
.getActiveSpreadsheet()
.getSheetByName('Responses');
const lastRow = sheet.getLastRow();
const name = sheet.getRange(lastRow, 1).getValue();
const email = sheet.getRange(lastRow, 2).getValue();
const status = sheet.getRange(lastRow, 3).getValue();
const message = `*New submission*\n• Name: ${name}\n• Email: ${email}\n• Status: ${status}`;
sendSlackMessage(message);
} catch (error) {
console.error('Slack notification failed:', error.message);
// Optionally: email yourself a fallback alert
GmailApp.sendEmail(
Session.getActiveUser().getEmail(),
'Slack Alert Failed',
error.message
);
}
}
```
The console.error output is visible in the Apps Script execution log (View → Executions). The fallback GmailApp.sendEmail ensures you still get alerted even when Slack itself is the problem.
## Common Issues and Fixes
Webhook returns 403 or 'No service': The webhook URL has been revoked or the Slack app was removed from the channel. Re-create the webhook and update the stored property. Trigger fires but no message arrives: Check the Executions log for errors. The most common cause is a missing or malformed SLACK_WEBHOOK_URL property — confirm it's set by logging PropertiesService.getScriptProperties().getProperty('SLACK_WEBHOOK_URL'). Message sends in editor but not on trigger: Triggers run as the user who installed them. Make sure the person who created the trigger has permission to read the sheet and that the PropertiesService property is stored at the Script level (not User level), so it's accessible regardless of who triggers it.
> Script properties are per-script, not per-user — store your webhook there, not in UserProperties, or it will be invisible when a trigger fires.
## Full Working Script (Copy-Paste Ready)
Here's everything in one block — store the webhook, send plain or rich messages, handle errors, and set up the trigger — ready to drop into any project:
```
// Run once to store your webhook URL
function setup() {
PropertiesService
.getScriptProperties()
.setProperty('SLACK_WEBHOOK_URL', 'https://hooks.slack.com/services/YOUR/WEBHOOK/URL');
// Create the form-submit trigger
const ss = SpreadsheetApp.getActiveSpreadsheet();
ScriptApp.newTrigger('notifySlackOnNewRow')
.forSpreadsheet(ss)
.onFormSubmit()
.create();
console.log('Setup complete.');
}
// Sends a plain-text message to Slack
function sendSlackMessage(message) {
const webhookUrl = PropertiesService
.getScriptProperties()
.getProperty('SLACK_WEBHOOK_URL');
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify({ text: message })
};
const response = UrlFetchApp.fetch(webhookUrl, options);
if (response.getResponseCode() !== 200) {
throw new Error('Slack API error: ' + response.getContentText());
}
}
// Fires on every new form submission
function notifySlackOnNewRow() {
try {
const sheet = SpreadsheetApp
.getActiveSpreadsheet()
.getSheetByName('Responses');
const lastRow = sheet.getLastRow();
const name = sheet.getRange(lastRow, 1).getValue();
const email = sheet.getRange(lastRow, 2).getValue();
const status = sheet.getRange(lastRow, 3).getValue();
const message =
`*New submission received* 🎉\n` +
`• *Name:* ${name}\n` +
`• *Email:* ${email}\n` +
`• *Status:* ${status}`;
sendSlackMessage(message);
} catch (err) {
console.error('Notification failed:', err.message);
GmailApp.sendEmail(
Session.getActiveUser().getEmail(),
'Slack Notification Failed',
err.message
);
}
}
```
## What to Build Next
Now that you can push messages from Sheets to Slack, a few natural extensions: alert a channel when a row's status column changes to 'Overdue', send a daily digest summary by combining a time-based trigger with a loop over all rows, or post to different channels based on a value in the sheet (use an if/else to pick between two webhook URLs stored as separate properties). For the time-based trigger approach, see our guide on [how to schedule a Google Apps Script to run automatically](/blog/how-to-schedule-google-apps-script-to-run-automatically).
[GS Copilot](/auth/signup) generates scripts like these from a single prompt — describe what you want in plain English and get the full, tested code back in seconds. [Free to try](/auth/signup), no install required.
Tags: Tutorial, Google Apps Script, Slack, Automation
URL: https://home.gscopilot.com/blog/send-slack-message-google-sheets-apps-script
---
### "How to Create a Google Calendar Event from a Google Sheet"
Category: Tutorial | Author: Hassan Raza | Date: Jun 18, 2026 | Read: 7 min read
You have a Google Sheet full of deadlines, meetings, or project milestones — and you want them on your calendar without copy-pasting each one by hand. Google Apps Script makes this a one-function job. The built-in CalendarApp service can read any row from your sheet and create a fully formed Google Calendar event in seconds. No Zapier, no Make, no third-party tools. By the end of this guide you will have a working script that creates a calendar event from a sheet row, a time-based trigger to run it automatically, and a clear mental model of how CalendarApp works so you can extend it yourself.
## What You Need Before Starting
You need a Google account with access to Google Sheets and Google Calendar — nothing else. The script runs entirely inside Google's infrastructure. Open a new or existing Google Sheet, then go to Extensions → Apps Script to open the script editor. That editor is where every snippet in this guide goes.
## Step 1 — Set Up Your Sheet Structure
Your sheet needs at least four columns so the script knows what event to create and when. Use this layout in Row 1 as headers:
```
| A: Event Title | B: Date | C: Start Time | D: End Time | E: Description |
|----------------|---------------|---------------|--------------|------------------------|
| Team Standup | 2026-06-20 | 09:00 | 09:30 | Daily sync call |
| Product Review | 2026-06-21 | 14:00 | 15:00 | Q3 roadmap walkthrough |
```
Dates in column B should follow the YYYY-MM-DD format and times in columns C and D should use 24-hour HH:MM notation. This keeps parsing predictable. If your existing sheet uses a different format, you can adjust the parsing logic in Step 2.
## Step 2 — Write the Core Function
Paste this script into the Apps Script editor and save it with Ctrl+S (or Cmd+S on Mac):
```
function createCalendarEventsFromSheet() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Sheet1');
const calendar = CalendarApp.getDefaultCalendar();
// Get all data rows, skipping the header (row 1)
const data = sheet.getRange(2, 1, sheet.getLastRow() - 1, 5).getValues();
data.forEach(function(row) {
const title = row[0]; // Column A
const dateStr = row[1]; // Column B e.g. '2026-06-20'
const startTime = row[2]; // Column C e.g. '09:00'
const endTime = row[3]; // Column D e.g. '09:30'
const description = row[4]; // Column E
// Skip empty rows
if (!title || !dateStr) return;
// Build full Date objects for start and end
const startDate = new Date(dateStr + 'T' + startTime + ':00');
const endDate = new Date(dateStr + 'T' + endTime + ':00');
calendar.createEvent(title, startDate, endDate, {
description: description
});
Logger.log('Created: ' + title + ' on ' + dateStr);
});
}
```
Click Run in the toolbar. The first time you run it, Google will ask you to authorise the script to access your Sheets and Calendar — click through the permissions and run it again. Check your Google Calendar and you should see every row from your sheet appear as an event.
## Step 3 — Create Events on a Specific Calendar (Not the Default)
If you want events to land on a specific calendar rather than your default one — a shared team calendar, for example — replace the getDefaultCalendar line with this:
```
// Replace with your calendar's exact name
const calendar = CalendarApp.getCalendarsByName('Project Deadlines')[0];
// Or use the calendar ID found in Calendar settings
const calendar = CalendarApp.getCalendarById('your-calendar-id@group.calendar.google.com');
```
You can find a calendar's ID by opening Google Calendar, clicking the three-dot menu next to the calendar name, selecting Settings, and scrolling to the Calendar ID field near the bottom of the page.
## Step 4 — Prevent Duplicate Events
If you run the script more than once, it will create duplicate events for every row. The safest fix is to track which rows have already been processed using a dedicated column — column F in this example:
```
function createCalendarEventsFromSheet() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Sheet1');
const calendar = CalendarApp.getDefaultCalendar();
const data = sheet.getRange(2, 1, sheet.getLastRow() - 1, 6).getValues();
data.forEach(function(row, index) {
const title = row[0];
const dateStr = row[1];
const startTime = row[2];
const endTime = row[3];
const description = row[4];
const status = row[5]; // Column F — 'Created' flag
// Skip empty rows and already-processed rows
if (!title || !dateStr || status === 'Created') return;
const startDate = new Date(dateStr + 'T' + startTime + ':00');
const endDate = new Date(dateStr + 'T' + endTime + ':00');
calendar.createEvent(title, startDate, endDate, {
description: description
});
// Mark this row as done in column F
sheet.getRange(index + 2, 6).setValue('Created');
Logger.log('Created: ' + title);
});
}
```
> Always mark processed rows. A script that creates 30 duplicate calendar events at 2 AM is not a good automation.
## Step 5 — Create All-Day Events
For deadlines, holidays, or milestones where you only care about the date and not a time window, use createAllDayEvent instead of createEvent:
```
// All-day event — only needs a title and a Date object for the day
const eventDate = new Date(dateStr);
calendar.createAllDayEvent(title, eventDate, {
description: description
});
```
You can mix timed events and all-day events in the same sheet by adding a Type column. If the cell says 'All Day', call createAllDayEvent; otherwise call createEvent with start and end times.
## Step 6 — Add Guests to the Event
CalendarApp lets you invite attendees directly when creating an event. Add a Guests column to your sheet (column G) with comma-separated email addresses, then pass them in the options object:
```
const guests = row[6]; // Column G — e.g. 'ali@example.com,sara@example.com'
calendar.createEvent(title, startDate, endDate, {
description: description,
guests: guests,
sendInvites: true // Set to false to add guests silently
});
```
Each invited guest receives an email invitation and can accept or decline from their own Google Calendar. This is especially useful for scheduling recurring review meetings or onboarding sessions directly from a planning sheet.
## Step 7 — Run the Script Automatically with a Trigger
Instead of running the script manually every time you add rows, set a time-based trigger to run it on a schedule. In the Apps Script editor, click Triggers (the clock icon on the left sidebar), then Add Trigger. Choose your function, set the event source to Time-driven, and pick how often it should run — every hour, every day, or on a specific schedule.
You can also set the trigger programmatically so it is version-controlled alongside your code:
```
function createTrigger() {
// Runs createCalendarEventsFromSheet every day at 8 AM
ScriptApp.newTrigger('createCalendarEventsFromSheet')
.timeBased()
.everyDays(1)
.atHour(8)
.create();
}
```
Run createTrigger once and it registers the daily schedule. Because the script checks the 'Created' flag in column F before acting, running it every day is completely safe — it only processes new, unprocessed rows.
## Putting It All Together — The Full Script
Here is the complete, production-ready version combining duplicate prevention, guest support, and both timed and all-day event types:
```
function createCalendarEventsFromSheet() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Sheet1');
const calendar = CalendarApp.getDefaultCalendar();
if (sheet.getLastRow() < 2) return; // Nothing to process
const data = sheet.getRange(2, 1, sheet.getLastRow() - 1, 7).getValues();
data.forEach(function(row, index) {
const title = row[0]; // A: Event Title
const dateStr = row[1]; // B: Date (YYYY-MM-DD)
const startTime = row[2]; // C: Start Time (HH:MM)
const endTime = row[3]; // D: End Time (HH:MM)
const description = row[4]; // E: Description
const status = row[5]; // F: Status flag
const guests = row[6]; // G: Guests (comma-separated emails)
if (!title || !dateStr || status === 'Created') return;
const options = { description: description };
if (guests) {
options.guests = guests;
options.sendInvites = true;
}
if (startTime && endTime) {
// Timed event
const startDate = new Date(dateStr + 'T' + startTime + ':00');
const endDate = new Date(dateStr + 'T' + endTime + ':00');
calendar.createEvent(title, startDate, endDate, options);
} else {
// All-day event
const eventDate = new Date(dateStr);
calendar.createAllDayEvent(title, eventDate, options);
}
sheet.getRange(index + 2, 6).setValue('Created');
Logger.log('Event created: ' + title + ' | ' + dateStr);
});
}
function createDailyTrigger() {
ScriptApp.newTrigger('createCalendarEventsFromSheet')
.timeBased()
.everyDays(1)
.atHour(8)
.create();
}
```
## Common Errors and How to Fix Them
Invalid Date: This almost always means your date or time column is formatted as a Date type in Sheets rather than plain text. Select the column, go to Format → Number → Plain text, then re-enter the values as YYYY-MM-DD strings.
Cannot read properties of undefined (reading '0'): The script found an empty row it tried to process anyway. The if (!title || !dateStr) guard handles this in the scripts above — make sure you have not removed it.
Calendar not found: If you are using getCalendarsByName and get an empty result, double-check the calendar name is spelled exactly as it appears in Google Calendar, including capitalisation and any special characters.
## What to Build Next
Once you have calendar events flowing from your sheet automatically, consider pairing this with the trigger on edit approach to create an event the moment a row is added — check out our guide on [Google Apps Script trigger on edit](/blog/apps-script-trigger-on-edit) for the exact setup. You can also extend the script to update or delete existing events by storing the event ID back in the sheet after creation, then using calendar.getEventById() to fetch and modify it later.
[Google Apps Script Copilot](/auth/signup) writes automation scripts like this one from a single prompt — [free to try](/auth/signup).
Tags: Google Apps Script, Google Calendar, Google Sheets, Automation
URL: https://home.gscopilot.com/blog/apps-script-create-calendar-event-from-sheet
---
### "How to Read and Write Google Drive Files with Apps Script"
Category: Tutorial | Author: Hassan Raza | Date: Jun 15, 2026 | Read: 7 min read
Google Drive is where most workspace data lives — reports, CSVs, PDFs, config files, and more. With Apps Script, you can read from and write to any file in your Drive without a single API key or OAuth setup. The built-in DriveApp service handles all of that for you. This guide covers the four things you'll actually do: read a text file, write or update a file, create a new file, and organize files into folders — all with copy-paste-ready code.
## How DriveApp Works (Quick Mental Model)
DriveApp is Apps Script's built-in bridge to Google Drive. Every file and folder in Drive is a DriveApp.File or DriveApp.Folder object. You find them by ID, by name, or by searching — and then call methods like getBlob(), getAs(), setContent(), or makeCopy() directly on those objects. No API credentials required. The script runs as the authenticated user, so it can only touch files that user can access.
> The file ID is the string in a Drive URL between /d/ and /view. Bookmark it — you'll use it constantly.
## Method 1: Read a Text File from Google Drive
The most common use case: you have a .txt or .csv sitting in Drive and you want to pull its contents into a script. Use DriveApp.getFileById() and then call getBlob().getDataAsString() to get the raw text.
```
function readTextFile() {
const FILE_ID = 'your-file-id-here'; // replace with your file's ID
const file = DriveApp.getFileById(FILE_ID);
const content = file.getBlob().getDataAsString();
Logger.log('File name: ' + file.getName());
Logger.log('Content: ' + content);
}
```
getDataAsString() defaults to UTF-8. If your file uses a different encoding, pass it as an argument: getDataAsString('ISO-8859-1'). For CSV files, you can then split the string on newlines and commas to parse rows and columns — or pipe it directly into a sheet using SpreadsheetApp.
## Method 2: Read a File by Name (When You Don't Have the ID)
If you only know the file name, use DriveApp.getFilesByName(). It returns an iterator, not a single file, because Drive allows duplicate names. Always call .hasNext() before grabbing the first result.
```
function readFileByName() {
const fileName = 'config.txt';
const files = DriveApp.getFilesByName(fileName);
if (!files.hasNext()) {
Logger.log('File not found: ' + fileName);
return;
}
const file = files.next();
const content = file.getBlob().getDataAsString();
Logger.log(content);
}
```
Searching by name is convenient, but brittle — rename the file and your script breaks. Use file IDs in production automations. Reserve name-based lookups for quick scripts or one-off tools.
## Method 3: Write to an Existing File
Apps Script can overwrite the content of a plain-text file using setContent(). This replaces everything currently in the file — there is no append method on text files natively, so if you need to append, read the content first, concatenate, then write it back.
```
function writeToFile() {
const FILE_ID = 'your-file-id-here';
const file = DriveApp.getFileById(FILE_ID);
file.setContent('Updated at: ' + new Date().toISOString());
Logger.log('File updated: ' + file.getName());
}
```
To append instead of overwrite, read first then write:
```
function appendToFile() {
const FILE_ID = 'your-file-id-here';
const newLine = '\nNew entry: ' + new Date().toISOString();
const file = DriveApp.getFileById(FILE_ID);
const existing = file.getBlob().getDataAsString();
file.setContent(existing + newLine);
}
```
## Method 4: Create a New File in Google Drive
To create a brand new file, use DriveApp.createFile(). Pass it a name and the content, and it lands in the root of your Drive. You can move it to a folder immediately after creation.
```
function createNewFile() {
const fileName = 'report-' + new Date().toISOString().slice(0, 10) + '.txt';
const content = 'Automated report generated by Apps Script.';
const newFile = DriveApp.createFile(fileName, content);
Logger.log('Created: ' + newFile.getUrl());
}
```
You can also create a file from a Blob — useful when you want to specify a MIME type, like creating a CSV that Drive recognizes properly:
```
function createCsvFile() {
const csvContent = 'Name,Email,Status\nAlice,alice@example.com,Active\nBob,bob@example.com,Inactive';
const blob = Utilities.newBlob(csvContent, 'text/csv', 'contacts-export.csv');
const file = DriveApp.createFile(blob);
Logger.log('CSV created: ' + file.getUrl());
}
```
## Method 5: Work with Folders
Dropping files into folders keeps Drive tidy and makes your automations predictable. You can get a folder by ID, create one if it doesn't exist, or move a file into it after creation.
```
function createFileInFolder() {
const FOLDER_ID = 'your-folder-id-here';
const folder = DriveApp.getFolderById(FOLDER_ID);
const file = folder.createFile('log.txt', 'Script ran at: ' + new Date());
Logger.log('File in folder: ' + file.getUrl());
}
```
To move an existing file into a folder, add it to the target folder and remove it from the current parent:
```
function moveFileToFolder() {
const FILE_ID = 'your-file-id-here';
const FOLDER_ID = 'your-folder-id-here';
const file = DriveApp.getFileById(FILE_ID);
const folder = DriveApp.getFolderById(FOLDER_ID);
// Add to target folder
folder.addFile(file);
// Remove from root (or its current parent)
DriveApp.getRootFolder().removeFile(file);
Logger.log('Moved: ' + file.getName());
}
```
## Method 6: List All Files in a Folder
Iterating over files in a folder is a common pattern — useful for batch processing, audits, or building a file inventory in a Sheet. DriveApp.getFolderById().getFiles() returns a FileIterator.
```
function listFilesInFolder() {
const FOLDER_ID = 'your-folder-id-here';
const folder = DriveApp.getFolderById(FOLDER_ID);
const files = folder.getFiles();
const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
sheet.clearContents();
sheet.appendRow(['Name', 'URL', 'Last Updated']);
while (files.hasNext()) {
const file = files.next();
sheet.appendRow([
file.getName(),
file.getUrl(),
file.getLastUpdated()
]);
}
Logger.log('Done — check your sheet.');
}
```
This is a clean way to build a live Drive inventory: run it on a daily trigger and you always have a fresh list in your spreadsheet. Pair it with the post on scheduling Apps Script triggers to automate this completely.
## Handling Large Files and Quotas
DriveApp works well for text files, CSVs, and small binaries. A few things to keep in mind as files get larger:
Apps Script has a 6-minute execution limit per run. Reading and writing very large files (tens of thousands of rows) inside that window is possible but tight — chunk your data or use triggers to spread the work across multiple executions. Google also caps daily URL Fetch and DriveApp calls for consumer accounts. If you hit quotas, the execution log will show a clear error. For higher limits, Google Workspace Business plans apply a more generous quota table — details are in the [Apps Script Quotas and Limits guide](https://developers.google.com/apps-script/guides/services/quotas).
## Common Errors and How to Fix Them
Exception: No item with the given ID exists — The file ID is wrong, or the running account doesn't have access to that file. Double-check the ID from the URL and confirm the file is shared with the account running the script.
Exception: You do not have permission to call DriveApp.createFile — The script hasn't been granted Drive scope yet. Run any DriveApp function once manually from the Apps Script editor; it will trigger the OAuth permission prompt.
File shows up in Drive but content is empty — setContent() with an empty string is valid. Check that your variable actually holds data before writing: Logger.log(content) right before the write call will confirm.
## Putting It All Together: A Practical Example
Here's a realistic end-to-end script: it reads a CSV from Drive, parses the rows, logs a summary to a new dated file, and drops that log into an archive folder.
```
function processAndLog() {
const CSV_FILE_ID = 'your-csv-file-id';
const ARCHIVE_FOLDER = 'your-archive-folder-id';
// 1. Read the source CSV
const csvFile = DriveApp.getFileById(CSV_FILE_ID);
const csvText = csvFile.getBlob().getDataAsString();
const rows = csvText.split('\n').filter(r => r.trim());
const dataRows = rows.slice(1); // skip header
// 2. Build a summary log
const summary =
'Run date : ' + new Date().toISOString() + '\n' +
'Rows processed: ' + dataRows.length + '\n' +
'Source file : ' + csvFile.getName();
// 3. Create the log file in the archive folder
const folder = DriveApp.getFolderById(ARCHIVE_FOLDER);
const logName = 'log-' + new Date().toISOString().slice(0, 10) + '.txt';
const logFile = folder.createFile(logName, summary);
Logger.log('Log saved: ' + logFile.getUrl());
}
```
Attach this to a daily time-based trigger and you have a self-maintaining audit trail with zero manual effort.
> [Google Apps Script Copilot](/auth/signup) writes any of these snippets in one prompt — [free to try](/auth/signup).
## Key Methods Reference
DriveApp.getFileById(id) — fetch a file by its ID. DriveApp.getFilesByName(name) — search by name, returns an iterator. file.getBlob().getDataAsString() — read file content as text. file.setContent(text) — overwrite a plain-text file. DriveApp.createFile(name, content) — create a new file in Drive root. folder.createFile(name, content) — create directly inside a folder. folder.addFile(file) — move a file into a folder. folder.getFiles() — iterate over all files in a folder. DriveApp.getFolderById(id) — fetch a folder by its ID.
## Next Steps
Now that you can read and write files in Drive, a natural next step is pairing this with Google Sheets — import that CSV directly into a spreadsheet, or export sheet data back to Drive on a schedule. The [Apps Script CRUD Operations with Google Sheets guide](/blog/apps-script-crud-google-sheets) covers exactly that pattern. And if you want these scripts to run without any manual trigger, see [How to Schedule a Google Apps Script to Run Automatically](/blog/how-to-schedule-google-apps-script-to-run-automatically).
Tags: Google Apps Script, Google Drive, Automation, Tutorial
URL: https://home.gscopilot.com/blog/apps-script-google-drive-read-write-files
---
### "Google Apps Script CRUD Operations with Google Sheets"
Category: Tutorial | Author: Hassan Raza | Date: Jun 12, 2026 | Read: 8 min read
CRUD — Create, Read, Update, Delete — is the backbone of almost every real automation. If your Google Sheets script does anything useful with data, it's doing at least one of these four things. This guide covers all four, with copy-paste code for each operation, so you can build on top of them immediately.
We'll use a simple contacts sheet (columns: ID, Name, Email, Status) as the example throughout. By the end you'll have four standalone functions you can drop into any project.
## Setting Up Your Sheet
Before writing any script, open Google Sheets and create a new spreadsheet. In the first row, add these headers exactly: ID, Name, Email, Status. Rename the sheet tab to Contacts. This is what the script will reference.
Open the Apps Script editor via Extensions → Apps Script. You'll write all four functions in the same Code.gs file. We'll start with a shared helper to keep things clean.
## The Helper: getSheet()
Every CRUD function needs a reference to the same sheet. Rather than repeating that lookup four times, put it in one place:
```
function getSheet() {
return SpreadsheetApp
.getActiveSpreadsheet()
.getSheetByName('Contacts');
}
```
All four functions below call getSheet() at the top. If you ever rename the tab, you only change one line.
## CREATE — Append a New Row
Adding a row is the simplest operation. appendRow() adds data at the first empty row below your existing data — no need to calculate which row to write to.
```
function createContact(id, name, email, status) {
const sheet = getSheet();
sheet.appendRow([id, name, email, status]);
Logger.log('Created: ' + name);
}
```
To test it, add a call at the bottom of your script and run it from the editor:
```
function testCreate() {
createContact(1, 'Sara Khan', 'sara@example.com', 'Active');
createContact(2, 'Ali Raza', 'ali@example.com', 'Inactive');
}
```
Run testCreate() and you'll see two rows appear in your Contacts sheet. That's your C.
> appendRow() is safe for concurrent writes only if you pair it with LockService. For solo scripts, it's fine as-is.
## READ — Find a Row by ID
Reading data means pulling all rows and searching for the one you want. getValues() returns a 2D array — one sub-array per row, one element per column.
```
function readContact(id) {
const sheet = getSheet();
const data = sheet.getDataRange().getValues();
// Skip the header row (index 0)
for (let i = 1; i < data.length; i++) {
if (data[i][0] === id) {
const [rowId, name, email, status] = data[i];
Logger.log(`Found: ${name} | ${email} | ${status}`);
return { rowIndex: i + 1, id: rowId, name, email, status };
}
}
Logger.log('Contact not found: ' + id);
return null;
}
```
Two things worth noting: the loop starts at index 1 to skip the header row, and the returned rowIndex is i + 1 because Apps Script row numbers are 1-based (the header is row 1, your first data row is row 2). You'll need that rowIndex for update and delete.
## UPDATE — Change a Value in an Existing Row
Updating a row means finding it first, then writing new values back to the same row. We'll reuse readContact() to locate the row, then use getRange() to overwrite it.
```
function updateContact(id, newName, newEmail, newStatus) {
const sheet = getSheet();
const contact = readContact(id);
if (!contact) {
Logger.log('Cannot update — contact not found: ' + id);
return;
}
// getRange(row, column, numRows, numColumns)
sheet.getRange(contact.rowIndex, 1, 1, 4).setValues([
[id, newName, newEmail, newStatus]
]);
Logger.log('Updated row ' + contact.rowIndex);
}
```
getRange(row, col, numRows, numCols) targets a specific rectangle of cells. Here we start at column 1, span 1 row and 4 columns, then write a 2D array with our new values. The outer array wraps the row, the inner array holds the cells — that's the shape setValues() expects.
If you only want to update a single field — say, just the Status — target that column specifically:
```
function updateStatus(id, newStatus) {
const sheet = getSheet();
const contact = readContact(id);
if (!contact) return;
// Column 4 = Status
sheet.getRange(contact.rowIndex, 4).setValue(newStatus);
Logger.log('Status updated to: ' + newStatus);
}
```
> Use setValue() (singular) for one cell. Use setValues() (plural) with a 2D array for a range. Mixing them up is one of the most common Apps Script mistakes.
## DELETE — Remove a Row Entirely
Deleting a row is one line once you know the row number. deleteRow() shifts everything below it up automatically.
```
function deleteContact(id) {
const sheet = getSheet();
const contact = readContact(id);
if (!contact) {
Logger.log('Cannot delete — contact not found: ' + id);
return;
}
sheet.deleteRow(contact.rowIndex);
Logger.log('Deleted contact ID: ' + id);
}
```
One warning: if you're deleting multiple rows in a loop, delete from the bottom up. Deleting row 3 shifts row 4 to become row 3 — if you then try to delete the original row 4, you'll miss it or hit the wrong row. Reverse-sort your row indexes before looping.
```
// Safe multi-delete — always delete bottom-up
function deleteMultiple(ids) {
const sheet = getSheet();
const contacts = ids
.map(id => readContact(id))
.filter(Boolean)
.sort((a, b) => b.rowIndex - a.rowIndex); // descending
contacts.forEach(c => {
sheet.deleteRow(c.rowIndex);
Logger.log('Deleted ID: ' + c.id);
});
}
```
## Putting It All Together
Here's a single test function that runs all four operations in sequence so you can see the full lifecycle:
```
function testAllCRUD() {
// CREATE
createContact(101, 'Zara Ahmed', 'zara@example.com', 'Active');
// READ
const found = readContact(101);
Logger.log(JSON.stringify(found));
// UPDATE
updateContact(101, 'Zara Ahmed', 'zara@newdomain.com', 'Inactive');
// DELETE
deleteContact(101);
Logger.log('CRUD cycle complete.');
}
```
Run testAllCRUD(), then open View → Logs to see each step confirmed. If anything fails, the Logger output tells you exactly where.
## Common Mistakes to Avoid
getDataRange() returns every cell that has ever had data, including blank rows that once had values. If your sheet looks empty but getDataRange() returns more rows than expected, use getLastRow() to bound your range precisely: sheet.getRange(2, 1, sheet.getLastRow() - 1, 4).getValues(). This skips the header and stops at the last real row.
Also watch out for type mismatches on IDs. If your ID column contains numbers and you're passing a string to readContact(), the strict equality check (===) will never match. Either always use the same type, or cast both sides: Number(data[i][0]) === Number(id).
## Batch Reads Are Faster Than Cell-by-Cell
Every call to getValue() or getRange() on a single cell is a round-trip to the Sheets API. If you're reading 50 rows one cell at a time, that's 50 API calls and your script will hit the execution time limit fast. Always read the entire data range once with getValues(), store it in a variable, and loop over the array in memory. This is the single biggest performance improvement you can make to any Apps Script.
```
// Slow — one API call per row
for (let i = 1; i <= sheet.getLastRow(); i++) {
const name = sheet.getRange(i, 2).getValue(); // bad
}
// Fast — one API call total
const allData = sheet.getDataRange().getValues();
for (let i = 1; i < allData.length; i++) {
const name = allData[i][1]; // good
}
```
The same principle applies to writes: batch your setValues() calls rather than calling setValue() inside a loop.
## What to Build Next
Once you have CRUD working, the next natural steps are triggering these operations automatically. You can wire createContact() to a Google Form submission so new entries land in your sheet the moment someone fills out the form — see our guide on [using Apps Script with Google Forms](/blog/google-apps-script-google-forms-full-guide). You can also pair your script with a time-based trigger to run cleanup deletes every night without touching the keyboard.
For anything more complex — filtering by multiple columns, paginating through large sheets, or exposing your CRUD operations as a REST endpoint — Apps Script scales there too. But the four functions above are the foundation everything else builds on.
[GS Copilot](/auth/signup) generates CRUD functions like these from a single prompt, already wired to your sheet structure — [free to try](/auth/signup).
Tags: Google Apps Script, Google Sheets, Automation, Tutorial
URL: https://home.gscopilot.com/blog/apps-script-crud-google-sheets
---
### "How to Schedule a Google Apps Script to Run Automatically"
Category: Tutorial | Author: Hassan Raza | Date: Jun 9, 2026 | Read: 6 min read
To run a Google Apps Script automatically on a schedule, you create a time-driven trigger. This is the Apps Script equivalent of a cron job: it tells Google to call one of your functions every few minutes, every hour, every day, or on a specific weekday — with no one sitting at the keyboard.
You can set it up in two ways: through the Triggers page in the editor (point-and-click), or in code with ScriptApp.newTrigger() — better for anything you want to version, share, or re-deploy.
Here's the shortest possible version. This runs myFunction once every day at around 8 AM:
```
function createDailyTrigger() {
ScriptApp.newTrigger('myFunction')
.timeBased()
.atHour(8)
.everyDays(1)
.create();
}
```
Run createDailyTrigger once, approve the permissions, and you're done. The rest of this guide covers the UI method, every schedule type you'll need, how to avoid the most common bug (duplicate triggers), and the timezone and quota gotchas that trip people up.
## Method 1: Schedule a Script from the Triggers Page (No Code)
If you only need a simple recurring schedule, the editor UI is the fastest route. Open your Apps Script project, click the clock icon (Triggers) in the left sidebar, then click + Add Trigger in the bottom right.
Configure the trigger: choose which function to run, set the event source to Time-driven, pick the type (Minutes timer, Hour timer, Day timer, Week timer), and set the interval. Click Save and approve the authorization prompt.
> UI triggers don't travel when you copy the project — for anything reusable, set them up in code.
## Method 2: Create a Time-Driven Trigger in Code
The ScriptApp service builds triggers programmatically. The pattern is always the same: name the function, call .timeBased(), chain the schedule, then .create().
### Run every N minutes
The cron-style classic. Valid values for everyMinutes() are 1, 5, 10, 15, and 30 — Google does not allow arbitrary minute counts.
```
function createMinuteTrigger() {
ScriptApp.newTrigger('myFunction')
.timeBased()
.everyMinutes(5)
.create();
}
```
### Run every N hours
```
function createHourlyTrigger() {
ScriptApp.newTrigger('myFunction')
.timeBased()
.everyHours(6)
.create();
}
```
### Run once a day at a set hour
atHour() takes a 0–23 value. The script runs within that hour window, not at the exact minute.
```
function createDailyTrigger() {
ScriptApp.newTrigger('sendDailyReport')
.timeBased()
.atHour(9) // ~9 AM in the script's timezone
.everyDays(1)
.create();
}
```
### Run on a specific weekday
Use onWeekDay() with the ScriptApp.WeekDay enum. This fires every Monday at around 8 AM:
```
function createWeeklyTrigger() {
ScriptApp.newTrigger('weeklyDigest')
.timeBased()
.onWeekDay(ScriptApp.WeekDay.MONDAY)
.atHour(8)
.create();
}
```
### Run once at a specific date and time
For a one-off job — say, a launch reminder — pass a Date object to .at():
```
function createOneTimeTrigger() {
const runAt = new Date('2026-12-25T08:00:00');
ScriptApp.newTrigger('holidayJob')
.timeBased()
.at(runAt)
.create();
}
```
## Stop Creating Duplicate Triggers (The #1 Bug)
Here's the trap: every time you run a function that calls .create(), Apps Script adds another trigger. Run your setup function five times during testing and you now have five copies of the same schedule firing in parallel. People discover this when their "daily" email arrives six times.
> Every call to .create() adds a new trigger — it never replaces the old one. Always delete before you recreate.
The fix is to clear existing triggers for that function before creating a new one. This helper deletes any trigger pointing at a given handler, then sets up exactly one:
```
function setupTrigger() {
const handlerName = 'myFunction';
// Remove existing triggers for this handler
const triggers = ScriptApp.getProjectTriggers();
for (const trigger of triggers) {
if (trigger.getHandlerFunction() === handlerName) {
ScriptApp.deleteTrigger(trigger);
}
}
// Create one fresh trigger
ScriptApp.newTrigger(handlerName)
.timeBased()
.everyHours(1)
.create();
}
```
Make setupTrigger your single source of truth. Run it whenever you want to reinstall the schedule, and you'll never end up with duplicates.
## Set the Right Timezone
Time-driven triggers fire according to your script's timezone, not your browser's. If atHour(8) is firing at the wrong time, the project timezone is almost always the reason.
Check and change it under Project Settings (the gear icon) → Time zone, or set it directly in the appsscript.json manifest:
```
{
"timeZone": "America/New_York"
}
```
Use a valid IANA timezone identifier like Europe/London or Asia/Kolkata. After changing it, re-run your setup function so existing triggers pick up the new zone.
## A Note on Precision
Apps Script triggers are approximate, not exact. An hourly trigger fires somewhere inside the hour; atHour(8) fires somewhere between 8:00 and 9:00. Google batches and distributes triggers across its infrastructure, so you cannot guarantee a script runs at exactly 8:00:00.
If you need minute-level precision, schedule an everyMinutes(5) trigger and have the function check the current time, exiting early if it isn't the window you want:
```
function preciseJob() {
const now = new Date();
if (now.getHours() === 8 && now.getMinutes() < 5) {
// do the work only in the 8:00–8:05 window
doTheActualWork();
}
}
```
## Watch Your Trigger Quotas
Time-driven triggers consume two limited resources, and exceeding either gets your triggers silently disabled: up to 20 triggers per user per script, and roughly 90 minutes of total runtime per day on consumer Gmail accounts (6 hours on Google Workspace).
> A script that runs every minute and takes 30 seconds each time burns 12 hours of runtime per day — well over any quota.
When a trigger fails, Google emails the script owner a failure summary. Keep an eye on that inbox while you're dialing things in.
## You're Now Scheduling Like a Cron Job
You can now run any Apps Script on autopilot: every few minutes, hourly, daily, weekly, or at a one-off moment — set up either through the UI or in code. Build your schedule with a single idempotent setupTrigger function, confirm your project timezone, and stay inside your runtime quota, and your automation will run reliably without you ever opening the editor again.
Writing trigger setup code by hand gets repetitive fast. [Google Apps Script Copilot](/auth/signup) writes this in one prompt — [free to try](/auth/signup). Describe the schedule you want in plain English and it generates the tested, duplicate-safe trigger code for you.
Tags: Google Apps Script, Automation, Tutorial
URL: https://home.gscopilot.com/blog/how-to-schedule-google-apps-script-to-run-automatically
---
### "How to Use Google Apps Script with Google Forms (Full Guide)"
Category: Tutorial | Author: Hassan Raza | Date: Jun 6, 2026 | Read: 6 min read
Google Apps Script lets you run custom code every time someone submits a Google Form. The key is the onFormSubmit trigger: you write a function, attach it to your form as an installable trigger, and it fires automatically on each submission. From there you can email the respondent, log answers to a Sheet, post to Slack, validate input, or call any API.
The fastest setup looks like this:
```
function onFormSubmit(e) {
const itemResponses = e.response.getItemResponses();
itemResponses.forEach(item => {
Logger.log('%s → %s', item.getItem().getTitle(), item.getResponse());
});
}
```
That's the whole idea. The rest of this guide shows you exactly how to attach that function as a trigger, read responses two different ways, send a confirmation email, and avoid the two mistakes that trip up almost everyone the first time.
## Form-Bound vs. Standalone Apps Script Projects
There are two ways to attach Apps Script to a Google Form, and the difference changes how your event object behaves.
A form-bound script lives inside the form itself. Open your form, click the three-dot menu, choose Apps Script, and you get an editor already connected to that form via FormApp.getActiveForm(). This is the simplest option for most automations.
A standalone or Sheet-bound script lives in a separate project (or in the response spreadsheet). You reach the form by ID with FormApp.openById('FORM_ID'). Use this when one project manages several forms or when your logic belongs with the response sheet.
> For this guide we'll use a form-bound script — the trigger setup is cleanest there, and it's the right starting point for almost every use case.
## Step 1: Open the Apps Script Editor from Your Form
In your Google Form, click the three-dot menu in the top right and select Apps Script. A new editor tab opens with an empty Code.gs file. Everything below goes in this file.
To grab a reference to the active form anywhere in your code:
```
function getForm() {
return FormApp.getActiveForm();
}
```
## Step 2: Understand the onFormSubmit Trigger Event Object
When a submission fires your trigger, Apps Script passes an event object (conventionally named e) to your function. What's inside e depends on where your script is bound.
A form-bound script gives you a FormResponse object:
```
function onFormSubmit(e) {
const formResponse = e.response; // FormResponse object
const items = formResponse.getItemResponses();
items.forEach(itemResponse => {
const question = itemResponse.getItem().getTitle();
const answer = itemResponse.getResponse();
Logger.log('%s: %s', question, answer);
});
// Respondent email, if your form collects it:
Logger.log('Submitted by: %s', formResponse.getRespondentEmail());
}
```
A Sheet-bound script (script lives in the linked response spreadsheet) gives you a flatter object instead:
```
function onFormSubmit(e) {
const rowValues = e.values; // array of answers, in column order
const named = e.namedValues; // object keyed by question title
Logger.log(rowValues); // ['2026-01-15 10:30', 'Jane', 'jane@x.com']
Logger.log(named['Email']); // ['jane@x.com'] ← always an array
}
```
> Mixing these up is the #1 source of bugs. If your form-bound code reaches for e.values, it'll be undefined. Match the event shape to where your script lives.
## Step 3: Add the onFormSubmit Trigger
Naming a function onFormSubmit is not enough to make it run automatically. Form submit is not a simple trigger — it requires an installable trigger, which you can add through the UI or in code.
Option A — Add it in the editor (easiest): In the Apps Script editor, click the clock icon (Triggers) in the left sidebar, then + Add Trigger. Choose your function, set the event source to From form, and the event type to On form submit. Save and approve the permissions prompt.
Option B — Add it programmatically: Run this setup function once. It checks for an existing trigger first so you don't create duplicates:
```
function createFormSubmitTrigger() {
const form = FormApp.getActiveForm();
// Remove any duplicate triggers for this function first.
ScriptApp.getProjectTriggers().forEach(t => {
if (t.getHandlerFunction() === 'onFormSubmit') {
ScriptApp.deleteTrigger(t);
}
});
ScriptApp.newTrigger('onFormSubmit')
.forForm(form)
.onFormSubmit()
.create();
Logger.log('Trigger installed.');
}
```
Run createFormSubmitTrigger once from the editor (select it in the function dropdown, click Run), grant permissions, and your onFormSubmit function will now fire on every submission. You only need to do this once per form.
## Step 4: Send a Confirmation Email on Submit
Here's the most common real-world use: email the respondent the moment they submit. This assumes your form collects email addresses (enable Collect email addresses in form settings).
```
function onFormSubmit(e) {
const formResponse = e.response;
const email = formResponse.getRespondentEmail();
if (!email) return; // No email collected — nothing to send.
// Build a quick summary of their answers.
let summary = '';
formResponse.getItemResponses().forEach(item => {
summary += `${item.getItem().getTitle()}: ${item.getResponse()}\n`;
});
GmailApp.sendEmail(
email,
'Thanks — we got your response',
`Hi,\n\nWe received your submission:\n\n${summary}\nWe'll be in touch soon.`
);
}
```
## Step 5: Write Form Responses to a Sheet (with Extra Logic)
Forms can already dump responses into a Sheet, but Apps Script lets you transform them first — add a status column, a timestamp in your own format, or a calculated field. This version appends a row to a sheet named Submissions:
```
function onFormSubmit(e) {
const ss = SpreadsheetApp.openById('YOUR_SHEET_ID');
const sheet = ss.getSheetByName('Submissions');
const responses = e.response.getItemResponses();
const row = responses.map(item => item.getResponse());
// Prepend a clean timestamp and a default status.
row.unshift(new Date());
row.push('New');
sheet.appendRow(row);
}
```
Replace YOUR_SHEET_ID with the ID from your spreadsheet's URL (the long string between /d/ and /edit).
## Step 6: Validate Submissions and Flag Problems
Because your code runs after submission, you can't block a bad answer — but you can catch it and act. This example flags submissions missing a phone number and notifies an admin:
```
function onFormSubmit(e) {
const named = {};
e.response.getItemResponses().forEach(item => {
named[item.getItem().getTitle()] = item.getResponse();
});
if (!named['Phone'] || named['Phone'].toString().trim() === '') {
GmailApp.sendEmail(
'admin@yourdomain.com',
'Incomplete form submission',
`Missing phone number from: ${named['Email'] || 'unknown'}`
);
}
}
```
## Common Errors and How to Fix Them
"Cannot read properties of undefined (reading 'getItemResponses')" — Your script is Sheet-bound but you're using e.response. Switch to e.values / e.namedValues, or move your code into the form-bound project.
The function never runs — You named it onFormSubmit but never installed the trigger. Run the setup function from Step 3 or add the trigger via the clock icon. Simple triggers don't cover form submit.
"You do not have permission to call GmailApp.sendEmail" — Run any function manually once from the editor to trigger the authorization prompt, then approve the scopes.
Trigger fires twice — You created the trigger more than once. The dedup logic in Step 3's setup function prevents this — re-run it to clean up.
## Putting It All Together
The full pattern for a form automation is always the same three moves: write an onFormSubmit(e) function, install it as an installable trigger, and read responses using the event shape that matches where your script lives. Once that scaffold is in place, swapping in Gmail, Sheets, Slack, or an external API is just a few lines.
For automations that fire on edits rather than submissions, see our complete guide to the [Apps Script on-edit trigger](/blog/apps-script-trigger-on-edit) — the trigger model is the same, only the event source changes.
> Stop writing Apps Script trigger boilerplate by hand — [Google Apps Script Copilot](/auth/signup) writes this in one prompt, [free to try](/auth/signup).
Tags: Google Apps Script, Google Forms, Automation, Tutorial
URL: https://home.gscopilot.com/blog/google-apps-script-google-forms-full-guide
---
### "Google Apps Script for Beginners: Your First Automation in 10 Minutes"
Category: Tutorials | Author: Hassan Raza | Date: Jun 3, 2026 | Read: 9 min read
Google Apps Script is a free, browser-based coding tool from Google that lets you automate Google Sheets, Gmail, Drive, Calendar, and the rest of Workspace using JavaScript. There's nothing to install and nothing to pay for — if you have a Google account, you already have it.
In this guide you'll write your first working automation in about 10 minutes: a script that reads tasks from a spreadsheet and emails you a reminder of everything that's still open. You don't need any prior coding experience. By the end you'll understand the three things every Apps Script project is made of — a script editor, functions you run, and triggers that run them for you.
## What Is Google Apps Script (in Plain English)?
Apps Script is JavaScript with a set of built-in shortcuts for talking to Google products. Instead of wiring up API keys and authentication like you would in most languages, you call objects like SpreadsheetApp, MailApp, and DriveApp directly, and Google handles the connection for you.
If you've used a formula in a spreadsheet, you've already done a tiny version of automation. Apps Script is the next step up: instead of one cell calculating one value, you can read an entire sheet, make decisions about the data, send emails, create calendar events, or update other files — all from a few lines of code that you can run on a schedule.
> The whole thing runs on Google's servers, not your computer. Your automation keeps working even when your laptop is closed.
## What You'll Build
Here's the finished project so you know where we're headed: a task tracker with a script that checks which tasks aren't done yet and emails you a tidy summary. Before you write any code, set up a simple sheet. Open a new Google Sheet, rename the tab at the bottom to Tasks, and add three columns — Task, Due Date, and Status — with a few rows of sample data. Mark at least one row as Done so you can see the filtering work.
## Step 1: Open the Apps Script Editor
With your sheet open, click Extensions in the top menu, then Apps Script. A new tab opens with the code editor. You'll see a file called Code.gs and a starter function. Delete that starter function — you're about to write your own. (.gs stands for Google Script. This is where all your code lives.)
## Step 2: Write Your First Function
A function is a named block of code you can run. Let's write the simplest possible one to confirm everything is wired up. Replace whatever's in the editor with this:
```
function sayHello() {
Logger.log("Hello from Apps Script!");
}
```
Logger.log() writes a message to a log you can read inside the editor. It's the Apps Script equivalent of "print," and it's the single most useful tool you have for checking what your code is doing. Save the project with Ctrl + S (or Cmd + S on Mac). Give it a name like "Task Reminder" when prompted.
## Step 3: Run It and Grant Permissions
At the top of the editor there's a toolbar with a Run button and a dropdown showing the function name. Make sure sayHello is selected, then click Run. The first time you run anything, Google asks for permission — your script wants to act on your behalf, so Google makes you approve it. Click Review permissions, choose your account, and on the "Google hasn't verified this app" screen click Advanced, then Go to your project name, then Allow. You only have to do this once per project.
> After it runs, open the Execution log at the bottom of the editor. You should see: Hello from Apps Script! — you just ran code on Google's servers.
## Step 4: Read Data From Your Sheet
Now let's connect to the spreadsheet. Add this new function below your first one:
```
function readTasks() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Tasks");
const data = sheet.getDataRange().getValues();
Logger.log(data);
}
```
SpreadsheetApp.getActiveSpreadsheet() grabs the spreadsheet this script is attached to. .getSheetByName("Tasks") selects the specific tab — this is why naming the tab mattered. .getDataRange() selects every cell that has data in it, automatically. .getValues() pulls those cells into a 2D array — a list of rows, where each row is itself a list of cell values. Select readTasks in the dropdown, click Run, and check the log. You'll see your data printed as nested brackets, with the header row first.
## Step 5: Filter for the Tasks That Matter
We don't want every row — we want the ones that aren't done. Replace readTasks with this improved version:
```
function getPendingTasks() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Tasks");
const data = sheet.getDataRange().getValues();
// Remove the header row (the first row)
const rows = data.slice(1);
// Keep only rows where the Status column isn't "Done"
const pending = rows.filter(function (row) {
return row[2] !== "Done";
});
Logger.log("You have " + pending.length + " pending task(s).");
Logger.log(pending);
}
```
data.slice(1) returns the array starting from the second row, throwing away the header. Arrays count from zero, so item 0 is the header and item 1 is your first real task. .filter() walks through every row and keeps only the ones where the test is true. row[2] is the third column (Status), because counting starts at zero: row[0] is Task, row[1] is Due Date, row[2] is Status. Run it and the log should report the number of unfinished tasks.
## Step 6: Turn It Into a Real Automation (Send the Email)
This is the payoff. We'll build the email body from the pending tasks and send it to yourself. Replace getPendingTasks with this final function:
```
function sendTaskReminder() {
const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheetByName("Tasks");
const data = sheet.getDataRange().getValues();
const rows = data.slice(1);
const pending = rows.filter(function (row) {
return row[2] !== "Done";
});
// Nothing to report? Stop early — don't send an empty email.
if (pending.length === 0) {
Logger.log("Nothing pending. No email sent.");
return;
}
// Build a readable list, one task per line
let body = "You have " + pending.length + " open task(s):\n\n";
pending.forEach(function (row) {
body += "• " + row[0] + " (due " + row[1] + ")\n";
});
// Send the email to yourself
const email = Session.getActiveUser().getEmail();
MailApp.sendEmail(email, "Your daily task reminder", body);
Logger.log("Reminder sent to " + email);
}
```
Session.getActiveUser().getEmail() returns your own email address, so you don't have to type it in. MailApp.sendEmail(recipient, subject, body) sends a plain-text email — three arguments: who, the subject line, and the message. The if (pending.length === 0) check with return is a small but important habit — it stops the function early so you never get a pointless "you have 0 tasks" email.
> Run sendTaskReminder and check your inbox. A reminder listing your open tasks should arrive within a few seconds. You've just built a complete automation.
## Step 7: Make It Run Automatically Every Morning
Right now you have to click Run yourself, which defeats the point. A trigger makes Google run the function for you on a schedule. The easy way is through the editor: click the clock icon (Triggers) in the left sidebar, then Add Trigger. Choose sendTaskReminder as the function, set the event source to Time-driven, pick Day timer, and select a time window like 8am–9am. Save it, and you'll get your reminder every morning without lifting a finger.
You can also create a trigger with code, which is handy once you're comfortable. Add and run this function once:
```
function createDailyTrigger() {
ScriptApp.newTrigger("sendTaskReminder")
.timeBased()
.everyDays(1)
.atHour(8)
.create();
}
```
This tells Apps Script: run sendTaskReminder every day, sometime in the 8am hour. Run createDailyTrigger a single time to set it up — running it repeatedly would stack up duplicate triggers.
## Common Beginner Mistakes (and Quick Fixes)
"TypeError: Cannot read properties of null" usually means getSheetByName() couldn't find your tab — check that the tab is spelled exactly Tasks, names are case-sensitive. Counting from one instead of zero is another classic: row[1] is the second column, not the first. Always save before running — the editor runs the saved version of your code. And never re-run setup functions like createDailyTrigger, or you'll stack up duplicate triggers. When something breaks, sprinkle Logger.log() lines through your code to see the value of things at each step.
## Where to Go Next
You now know the core loop that powers every Apps Script project: open the editor, write a function, run it, and attach a trigger to automate it. Good next steps are learning the SpreadsheetApp methods you'll use constantly, connecting to external services with UrlFetchApp, and formatting richer emails with HTML. The single biggest leap in skill comes from building tiny scripts for problems you actually have — a weekly summary, a form-response notifier, an auto-archiver for old files. Each one teaches you one new method, and they compound fast.
Writing this kind of script by hand gets faster with practice, but you don't have to memorize every method. [Google Apps Script Copilot](/auth/signup) writes this in one prompt — [free to try](/auth/signup): describe the automation in plain English and get clean, copy-paste-ready code without leaving your browser.
Tags: Google Apps Script, Beginners, Automation, Google Sheets
URL: https://home.gscopilot.com/blog/google-apps-script-for-beginners
---
### "Copy a Row to Another Sheet in Google Apps Script (3 Methods)"
Category: Tutorial | Author: Hassan Raza | Date: May 31, 2026 | Read: 6 min read
If you've ever needed to move or duplicate data between sheets automatically, you're in the right place. In Google Apps Script, copying a row to another sheet is one of the most common automation tasks — and there are several ways to do it depending on your exact needs.
This guide covers 3 methods to copy a row to another sheet using Google Apps Script, with copy-paste ready code for each. Whether you want to copy based on a condition, copy on edit, or copy an entire row in bulk, there's a method here for you.
## Why Copy Rows Between Sheets with Apps Script?
Manual copy-pasting between sheets breaks the moment you add new data. Apps Script automates this permanently. Common use cases include moving completed tasks from an Active sheet to an Archive sheet, routing form responses to separate sheets by category, copying approved entries to a summary sheet, and syncing data from one tab to another based on a status column.
## Before You Start: Open the Script Editor
Open your Google Sheet, click Extensions → Apps Script, delete any placeholder code in the editor, paste whichever method you need below, then click Save and Run. You'll be prompted to authorize the script on first run — that's expected.
## Method 1: Copy a Specific Row by Row Number
This is the simplest approach. Use it when you know exactly which row you want to copy, or when you're building a more complex script and need a reliable copy function.
```
function copyRowToAnotherSheet() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sourceSheet = ss.getSheetByName("Sheet1");
const targetSheet = ss.getSheetByName("Sheet2");
const rowNumber = 3; // Change this to the row you want to copy
const lastCol = sourceSheet.getLastColumn();
// Get all values in the row
const rowData = sourceSheet.getRange(rowNumber, 1, 1, lastCol).getValues();
// Append to the next available row in the target sheet
targetSheet.getRange(targetSheet.getLastRow() + 1, 1, 1, lastCol).setValues(rowData);
}
```
This reads all values from row 3 of Sheet1 and appends them to the next empty row in Sheet2. Use it for quick one-off copies, or as a utility function inside a larger automation.
## Method 2: Copy Rows Based on a Condition (Most Useful)
This is the most practical method. It loops through all rows in your source sheet and copies any row that meets a condition — like a status column saying "Done" or "Approved".
```
function copyRowsBasedOnCondition() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sourceSheet = ss.getSheetByName("Sheet1");
const targetSheet = ss.getSheetByName("Archive");
const conditionColumn = 4; // Column D — change to your status column
const conditionValue = "Done"; // Change to whatever triggers the copy
const data = sourceSheet.getDataRange().getValues();
const rowsToCopy = [];
for (let i = 1; i < data.length; i++) { // Start at 1 to skip header row
if (data[i][conditionColumn - 1] === conditionValue) {
rowsToCopy.push(data[i]);
}
}
if (rowsToCopy.length === 0) {
Logger.log("No matching rows found.");
return;
}
// Write all matching rows at once (much faster than row-by-row)
const startRow = targetSheet.getLastRow() + 1;
targetSheet.getRange(startRow, 1, rowsToCopy.length, rowsToCopy[0].length).setValues(rowsToCopy);
Logger.log(`Copied ${rowsToCopy.length} row(s) to Archive.`);
}
```
> Writing all rows at once with a single setValues() call is far faster than looping and writing row-by-row. This matters a lot when dealing with hundreds of rows.
This reads all rows from Sheet1, finds every row where column D equals "Done", and copies all matching rows to the Archive sheet in one batch write. Use it for filtering completed tasks, archiving entries, and routing by status.
## Method 3: Copy a Row Automatically on Edit (Trigger-Based)
This method fires automatically every time someone edits the sheet. When the value in a specific column changes to your trigger value, the row gets copied to another sheet instantly — no manual running required.
```
function onEdit(e) {
const triggerColumn = 5; // Column E — the column to watch
const triggerValue = "Approved"; // The value that triggers the copy
const sourceSheetName = "Requests";
const targetSheetName = "Approved";
const sheet = e.source.getActiveSheet();
// Only run on the correct sheet and column
if (sheet.getName() !== sourceSheetName) return;
if (e.range.getColumn() !== triggerColumn) return;
if (e.value !== triggerValue) return;
const row = e.range.getRow();
const ss = e.source;
const targetSheet = ss.getSheetByName(targetSheetName);
const lastCol = sheet.getLastColumn();
const rowData = sheet.getRange(row, 1, 1, lastCol).getValues();
targetSheet.getRange(targetSheet.getLastRow() + 1, 1, 1, lastCol).setValues(rowData);
Logger.log(`Row ${row} copied to ${targetSheetName}`);
}
```
This watches column E of the Requests sheet. When someone changes a cell in that column to "Approved", the entire row is instantly copied to the Approved sheet. It does nothing for edits in other columns or other sheets.
> The onEdit trigger runs automatically — you do not need to set up a separate trigger in Apps Script. It's a simple trigger that Google runs for you whenever a cell is edited.
Use this method for approval workflows, real-time data routing, and instant archiving.
## How to Copy a Row AND Delete It from the Source Sheet
Sometimes you want to move a row rather than copy it. Add a delete step after copying:
```
function moveRowToAnotherSheet() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sourceSheet = ss.getSheetByName("Sheet1");
const targetSheet = ss.getSheetByName("Sheet2");
const rowNumber = 3; // Row to move
const lastCol = sourceSheet.getLastColumn();
// Copy the row
const rowData = sourceSheet.getRange(rowNumber, 1, 1, lastCol).getValues();
targetSheet.getRange(targetSheet.getLastRow() + 1, 1, 1, lastCol).setValues(rowData);
// Delete from source
sourceSheet.deleteRow(rowNumber);
}
```
Note: deleteRow() shifts all rows below it up by one. If you're deleting multiple rows in a loop, iterate from the bottom up to avoid skipping rows.
## Common Errors and Fixes
"Cannot find sheet 'Sheet2'" — The sheet name in your code doesn't match exactly. Sheet names are case-sensitive. Check for extra spaces too.
Copied row has the wrong number of columns — Make sure lastCol reflects your source sheet, not the target. Call getLastColumn() on the correct sheet object.
onEdit isn't firing — Make sure your function is named exactly onEdit — that's the reserved name Apps Script uses. Also confirm you're editing the correct sheet.
Script times out on large datasets — Use batch reads and writes (like Method 2 above) instead of reading/writing one cell at a time inside a loop. This alone can make scripts 10–100x faster.
## Which Method Should You Use?
Copy a known row manually → Method 1. Copy all rows matching a filter → Method 2. Copy automatically when a cell changes → Method 3. Move (copy + delete) a row → the move variant above.
## Take It Further
Once you can copy rows between sheets, the next step is usually adding conditions, sending notifications, or scheduling the automation to run on a timer. Combine Method 2 with a time-driven trigger so it runs every morning automatically, or after copying, email yourself or your team when rows move.
Or skip the scripting entirely — [Google Apps Script Copilot](/auth/signup) writes all of this code for you in one prompt, directly in your browser. [Free to try](/auth/signup).
Tags: Google Apps Script, Google Sheets, Automation
URL: https://home.gscopilot.com/blog/copy-row-to-another-sheet-apps-script
---
### "Google Apps Script Trigger on Edit: Complete Guide with Examples"
Category: Tutorial | Author: Hassan Raza | Date: May 28, 2026 | Read: 8 min read
The Apps Script trigger on edit fires every time a user changes a cell in your Google Sheet. With two lines of code you can log the change, validate the input, send a notification, or kick off an entire workflow — all without leaving the browser. This guide covers everything: how the simple onEdit trigger works, when you need an installable trigger instead, common pitfalls, and five ready-to-use code examples.
## What is an onEdit Trigger in Google Apps Script?
An onEdit trigger is a function that Google Apps Script runs automatically whenever a cell value changes in a spreadsheet. You don't call it yourself — Google calls it for you, passing an event object (usually written as e) that tells you exactly what changed: the new value, the old value, the row, the column, and the sheet name.
There are two flavors: the simple trigger and the installable trigger. They look almost identical in code, but they have very different permissions and behaviors. Choosing the wrong one is the most common reason onEdit stops working.
## Simple Trigger vs Installable Trigger: The Key Difference
A simple trigger is any function you name exactly onEdit. It runs with the same permissions as the user currently viewing the sheet, requires no authorization dialog, and activates the instant a cell is edited. The downside: it cannot access services that require authorization — so no sending email, no writing to Drive, no calling external APIs.
An installable trigger is one you register through the Apps Script UI or via ScriptApp.newTrigger(). It runs under the account of the person who installed it, so it can do everything a simple trigger cannot — send Gmail, update Calendar, post to Slack. You pay for that power with an extra setup step and a short delay (usually under a second).
> Rule of thumb: if your onEdit function only reads and writes cells, use a simple trigger. The moment it touches anything outside the spreadsheet, switch to an installable trigger.
## The onEdit Event Object (e) Explained
Both trigger types receive the same event object. Understanding its properties is essential before writing any real logic.
```
function onEdit(e) {
const range = e.range; // The cell or range that was edited
const sheet = range.getSheet(); // The Sheet object
const row = range.getRow(); // Row number (1-indexed)
const col = range.getColumn(); // Column number (1-indexed)
const newVal = e.value; // New cell value (string)
const oldVal = e.oldValue; // Previous value (undefined if cell was empty)
const sheetName = sheet.getName(); // e.g. "Sheet1"
Logger.log(`Edited ${sheetName} R${row}C${col}: '${oldVal}' → '${newVal}'`);
}
```
One gotcha: e.value is always a string. If the user typed 42, you get '42'. Parse it with Number() or parseInt() before doing any arithmetic. Also, e.oldValue is undefined when the cell was previously blank — always guard against that.
## Example 1: Log Every Edit to a History Sheet
This is the simplest useful pattern — a running audit trail of every change made to your spreadsheet, with a timestamp and the editor's email.
```
function onEdit(e) {
const ss = e.source;
const logSheet = ss.getSheetByName('Edit Log') || ss.insertSheet('Edit Log');
const range = e.range;
const sheet = range.getSheet();
// Skip logging changes made to the log sheet itself
if (sheet.getName() === 'Edit Log') return;
logSheet.appendRow([
new Date(), // Timestamp
Session.getActiveUser().getEmail(), // Editor
sheet.getName(), // Sheet name
range.getA1Notation(), // Cell address, e.g. 'B4'
e.oldValue || '(empty)', // Previous value
e.value || '(empty)' // New value
]);
}
```
Paste this into your script file, save, and start editing cells. The 'Edit Log' sheet appears automatically on the first edit. No setup required — this is a simple trigger, so it needs no authorization.
## Example 2: Validate Input and Reject Invalid Values
Simple triggers can write back to the sheet, which makes them perfect for lightweight validation. Here, column C on 'Orders' must always be a positive number. Any other input gets reverted with an alert.
```
function onEdit(e) {
const range = e.range;
const sheet = range.getSheet();
// Only validate column C on the Orders sheet
if (sheet.getName() !== 'Orders' || range.getColumn() !== 3) return;
const val = Number(e.value);
if (isNaN(val) || val <= 0) {
// Revert to old value (or clear the cell)
range.setValue(e.oldValue || '');
SpreadsheetApp.getUi().alert(
`Invalid quantity: '${e.value}'. Please enter a positive number.`
);
}
}
```
The early return on the first line is critical for performance. Without it, the function runs for every edit across the entire spreadsheet, even ones you don't care about. Always filter by sheet name and column or row before doing any real work.
## Example 3: Auto-Stamp a Timestamp When a Row Is Updated
A very common request: whenever someone fills in column B, automatically write the current date and time into column A of the same row. This pattern works for status updates, form responses, or task trackers.
```
function onEdit(e) {
const range = e.range;
const sheet = range.getSheet();
// Only react to edits in column B (Status column)
if (sheet.getName() !== 'Tasks' || range.getColumn() !== 2) return;
if (!e.value) return; // Don't stamp if the cell was cleared
// Write timestamp to column A, same row
sheet
.getRange(range.getRow(), 1)
.setValue(new Date())
.setNumberFormat('yyyy-MM-dd HH:mm:ss');
}
```
## Example 4: Send an Email When a Cell Changes (Installable Trigger)
This requires an installable trigger because GmailApp.sendEmail() needs authorization. First, set up the trigger programmatically — run this function once from the Apps Script editor:
```
// Run this once to install the trigger
function installTrigger() {
const ss = SpreadsheetApp.getActiveSpreadsheet();
ScriptApp.newTrigger('onEditInstallable')
.forSpreadsheet(ss)
.onEdit()
.create();
}
// Your actual handler — can now use GmailApp, DriveApp, etc.
function onEditInstallable(e) {
const range = e.range;
const sheet = range.getSheet();
// Watch for 'Approved' in column D of the Contracts sheet
if (sheet.getName() !== 'Contracts' || range.getColumn() !== 4) return;
if (e.value !== 'Approved') return;
const row = range.getRow();
const contractName = sheet.getRange(row, 1).getValue(); // Column A = name
const clientEmail = sheet.getRange(row, 2).getValue(); // Column B = email
GmailApp.sendEmail(
clientEmail,
`Your contract '${contractName}' has been approved`,
`Hi,\n\nGreat news — your contract has been approved and is now active.\n\nThanks,\nThe Team`
);
}
```
After running installTrigger() once, delete it or comment it out. You can verify the trigger was created by going to Extensions → Apps Script → Triggers (the alarm clock icon in the left sidebar). You'll see onEditInstallable listed there.
## Example 5: Sync a Row to Another Sheet on Edit
When a row's status in 'Pipeline' is changed to 'Closed', copy the entire row to an 'Archive' sheet and delete it from Pipeline. This is a simple trigger because it only touches the spreadsheet.
```
function onEdit(e) {
const range = e.range;
const sheet = range.getSheet();
if (sheet.getName() !== 'Pipeline' || range.getColumn() !== 5) return; // Column E = Status
if (e.value !== 'Closed') return;
const ss = e.source;
const archive = ss.getSheetByName('Archive') || ss.insertSheet('Archive');
const row = range.getRow();
const lastCol = sheet.getLastColumn();
// Copy the entire row to Archive
const rowData = sheet.getRange(row, 1, 1, lastCol).getValues();
archive.appendRow(rowData[0]);
// Delete from Pipeline
sheet.deleteRow(row);
}
```
> Never call getValues() or setValues() inside a loop when you can batch the read and write outside it. Every service call to the Sheets API costs time — batch calls are 10–100× faster.
## Common onEdit Trigger Problems (and How to Fix Them)
The trigger isn't running at all — make sure the function is named exactly onEdit (no typos, no capital E). Simple triggers will not run if the script file has any syntax errors, even in an unrelated function. Open the Apps Script editor and check the console for red errors.
The trigger runs but throws an authorization error — you're calling an authorized service (Gmail, Drive, Calendar) from a simple trigger. Convert to an installable trigger using the installTrigger() pattern shown in Example 4.
The trigger fires twice — you likely have both a simple onEdit function and an installed trigger pointing at the same handler. Check your triggers list and delete the duplicate.
e.value is undefined — this happens when the edit was triggered by a script (e.g., setValue() calls), not by a human typing. Apps Script does not fire onEdit for programmatic changes, only for user edits in the browser.
## onEdit Trigger Quotas to Know
Google Apps Script enforces execution time limits. A single trigger execution cannot run longer than 30 seconds (6 minutes for some Workspace accounts). If your onEdit function does heavy work — large data reads, external API calls — it will time out. Keep onEdit handlers fast: read only what you need, write only what changed, and offload long tasks to a time-based trigger that processes a queue.
## Write Your onEdit Trigger in One Prompt
Every example in this guide was written and tested the way most Apps Script developers now work: describe what you want in plain English, get working code back instantly. [Google Apps Script Copilot](/auth/signup) is a browser-native AI built specifically for Apps Script — it knows the event object, the quota limits, and the installable vs simple trigger distinction so you don't have to memorize any of it. [Free to try](/auth/signup), no install required.
Tags: Tutorial, Google Apps Script, Triggers, Google Sheets
URL: https://home.gscopilot.com/blog/apps-script-trigger-on-edit
---
### "How to Send Email from Google Sheets Using Apps Script (2026 Guide)"
Category: Tutorial | Author: Hassan Raza | Date: May 25, 2026 | Read: 6 min read
If you want to send email from Google Sheets using Apps Script, here's the short answer: use MailApp.sendEmail() or GmailApp.sendEmail() inside a script bound to your sheet. Both methods are free, require no third-party tools, and work with any Gmail account. You can trigger them manually, on a schedule, or when a row is edited — making it the go-to solution for automated notifications, reports, and alerts straight from your spreadsheet.
This guide walks you through every method with copy-paste-ready code.
## What You Need Before You Start
You need a Google account with a Google Sheet, access to Extensions → Apps Script inside that sheet, and basic familiarity with reading code — no prior JavaScript required.
Apps Script is Google's built-in scripting platform. It lives inside your Google Workspace tools and has native access to Gmail, Sheets, Drive, and Calendar — no API keys or OAuth setup needed for basic email sending.
## Method 1: Send a Simple Email from a Sheet
This is the fastest way to get started. Open your spreadsheet, click Extensions → Apps Script, delete any placeholder code, and paste this:
```
function sendEmailFromSheet() {
var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
var recipient = sheet.getRange("B2").getValue(); // email address in cell B2
var subject = sheet.getRange("B3").getValue(); // subject in cell B3
var body = sheet.getRange("B4").getValue(); // message body in cell B4
MailApp.sendEmail(recipient, subject, body);
}
```
Click Save, then click Run. The first time you run it, Google will ask for permission to access your Gmail — click Allow. Your email lands in the recipient's inbox within seconds.
> MailApp sends email using your Google account as the sender. It's simpler than GmailApp and has a daily quota of 100 emails (500 for Google Workspace accounts).
## Method 2: Send Email to Multiple Recipients from a Sheet
If you have a list of email addresses in a column, loop through each row and send to each one:
```
function sendBulkEmailFromSheet() {
var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
var lastRow = sheet.getLastRow();
for (var i = 2; i <= lastRow; i++) {
var email = sheet.getRange(i, 1).getValue(); // column A: email
var name = sheet.getRange(i, 2).getValue(); // column B: name
var status = sheet.getRange(i, 3).getValue(); // column C: status flag
if (status !== "Sent") {
MailApp.sendEmail(email, "Your Update", "Hi " + name + ", here is your update.");
sheet.getRange(i, 3).setValue("Sent");
}
}
}
```
> The status !== "Sent" check is critical — it prevents duplicate emails if you run the script more than once. Always add this guard when sending bulk email.
## Method 3: Send an HTML Email from Google Sheets
Plain text emails get the job done, but HTML lets you add formatting, buttons, and branded layouts. Pass an options object instead of individual arguments when you need HTML:
```
function sendHtmlEmailFromSheet() {
var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
var recipient = sheet.getRange("B2").getValue();
var name = sheet.getRange("B3").getValue();
var htmlBody = `
Hello, ${name}!
This is your automated update from Google Sheets.
View Report
`;
MailApp.sendEmail({
to: recipient,
subject: "Your Weekly Report",
htmlBody: htmlBody
});
}
```
## Method 4: Trigger Email Automatically When a Row is Edited
Instead of running the script manually, you can fire it automatically whenever someone edits your sheet. In the Apps Script editor, click the Triggers icon (clock symbol on the left sidebar), then Add Trigger. Or set it up programmatically:
```
function createEditTrigger() {
var ss = SpreadsheetApp.getActiveSpreadsheet();
ScriptApp.newTrigger("sendEmailFromSheet")
.forSpreadsheet(ss)
.onEdit()
.create();
}
```
Run createEditTrigger() once. From that point on, every edit to the sheet fires your email function. Add column-specific logic inside sendEmailFromSheet() to control exactly when an email should go out.
## Method 5: Use GmailApp for More Control
GmailApp gives you extra options — CC, BCC, reply-to address, and file attachments. It also carries a higher daily quota (1,500 emails for Workspace) and is the right choice when you need attachments or more routing options.
```
function sendEmailWithGmailApp() {
var sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
var recipient = sheet.getRange("B2").getValue();
GmailApp.sendEmail(recipient, "Report Ready", "See attached.", {
cc: "manager@example.com",
bcc: "archive@example.com",
replyTo: "noreply@example.com",
attachments: [DriveApp.getFileById("YOUR_FILE_ID").getAs(MimeType.PDF)]
});
}
```
Replace YOUR_FILE_ID with the ID from the Google Drive file URL.
## Common Errors and How to Fix Them
"You do not have permission to call MailApp.sendEmail" — you skipped the authorization step. Run the function manually once and click Allow in the popup.
Emails going to spam — this happens when recipients haven't interacted with your domain. Add a plain-text version alongside the HTML body using the body parameter in your options object.
"Service invoked too many times" — you've hit the daily quota. Add the "Sent" status flag shown in Method 2 to prevent repeat sends.
Script times out — Apps Script cuts off after 6 minutes. For large lists, split sending across multiple trigger runs using PropertiesService to store your position.
## Quotas to Know
Free Gmail accounts are limited to 100 emails per day via both MailApp and GmailApp. Google Workspace accounts get 1,500 emails per day. Recipients in CC and BCC both count toward this total.
## What to Build Next
Now that you can send email from a sheet, the next logical step is automating when it fires. Check out our guide on the [Google Apps Script on-edit trigger](/blog/apps-script-trigger-on-edit) to set up event-based automation from spreadsheet changes.
[Google Apps Script Copilot](/auth/signup) writes this entire script from a plain English description in one prompt — [free to try](/auth/signup).
Tags: Tutorial, Google Sheets, Apps Script, Gmail, Automation
URL: https://home.gscopilot.com/blog/send-email-from-google-sheets-apps-script
## SOCIAL PROOF
"I rewrote our entire Workspace automation stack in a weekend. The full-project context is the
killer feature — Cursor, but for the one IDE I can't replace." — Aliya B., Senior Apps Script
Engineer at a logistics company
"Lifetime + BYOK was an instant yes. I plug in my Anthropic key, pay $0.40/day in tokens, and
get an AI that actually understands SpreadsheetApp.getActive()." — Cody P., Workspace Consultant
"Onboarded our 6-person ops team in one stand-up. They went from 'what's a trigger' to shipping
internal tools in a week." — Marisol T., Head of RevOps at a SaaS startup
"I'm not a developer. I run a bakery. Google Apps Script Copilot lets me automate my Sheets like I have an
engineer on staff." — Renee D., Owner, Sourdough & Co.
---
## TECHNICAL DETAILS
- Chrome extension (also works in Brave, Edge)
- Mounts panel inside script.google.com via content script
- Model calls streamed directly from browser to provider (on BYOK plans)
- Key storage: IndexedDB, encrypted with WebCrypto subtle API, no server-side mirror
- Apps Script runtime supported: V8
- Deployed on Cloudflare Workers
- Supports all Google Workspace-connected script environments: Sheets, Docs, Drive, Gmail,
Calendar, Forms, Slides, and standalone scripts
---
## LINKS
- Homepage: https://home.gscopilot.com
- Pricing: https://home.gscopilot.com/pricing
- Installation guide: https://home.gscopilot.com/guide
- Comparisons: https://home.gscopilot.com/compare
- Blog: https://home.gscopilot.com/blog
- Changelog: https://home.gscopilot.com/changelogs
- Roadmap: https://home.gscopilot.com/roadmap
- About: https://home.gscopilot.com/about
- Chrome Web Store: https://chromewebstore.google.com/detail/google-apps-script-copilo/aakmllddlcknkbcgjabmcgggfciofbgo
- Contact: https://home.gscopilot.com/contact