<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: bulldo.gs</title>
    <description>The latest articles on DEV Community by bulldo.gs (@bulldo_gs).</description>
    <link>https://dev.to/bulldo_gs</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3982014%2Fe84d8d78-f364-4692-8ae1-8c194c4f064a.png</url>
      <title>DEV Community: bulldo.gs</title>
      <link>https://dev.to/bulldo_gs</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/bulldo_gs"/>
    <language>en</language>
    <item>
      <title>Run a script every day at a set time with Apps Script</title>
      <dc:creator>bulldo.gs</dc:creator>
      <pubDate>Sat, 13 Jun 2026 00:50:58 +0000</pubDate>
      <link>https://dev.to/bulldo_gs/run-a-script-every-day-at-a-set-time-with-apps-script-3kp9</link>
      <guid>https://dev.to/bulldo_gs/run-a-script-every-day-at-a-set-time-with-apps-script-3kp9</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally written for &lt;a href="https://bulldo.gs/run-a-script-every-day-at-a-set-time-apps-script" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; — republished here with the canonical link pointing home.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I want to schedule a Google Apps Script function to run automatically at a specific time every day without having to trigger it manually.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Run myDailyJob at 8 AM every day (script timezone).&lt;/span&gt;
&lt;span class="c1"&gt;// Call createDailyTrigger() once from the editor — never from a trigger.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;createDailyTrigger&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;triggers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ScriptApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getProjectTriggers&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;triggers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;triggers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;getHandlerFunction&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;myDailyJob&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;ScriptApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;triggers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;ScriptApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;newTrigger&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;myDailyJob&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;timeBased&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;everyDays&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;atHour&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;8&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;create&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;myDailyJob&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="nx"&gt;Logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Running daily job at: &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What atHour actually promises
&lt;/h2&gt;

&lt;p&gt;Apps Script time-based triggers do not fire at a precise clock time. When you call atHour(8), you are telling the scheduler to run your function sometime between 8:00 and 9:00 AM in the script's timezone — not at exactly 08:00:00. Google's infrastructure batches trigger execution and spreads it across that window. If your job must run at exactly 8:05, Apps Script is the wrong tool; use Cloud Scheduler calling a Cloud Function instead.&lt;/p&gt;

&lt;p&gt;The script's timezone is set per project, not per user. Go to Project Settings (the gear icon in the left sidebar) and check the Time zone field. If that reads America/New_York and your Sheets data is in UTC, every atHour call is offset accordingly. I've burned a morning debugging a report that always showed yesterday's data — the trigger was firing at what was effectively midnight UTC.&lt;/p&gt;

&lt;p&gt;One practical consequence: if you chain two time-based triggers expecting a gap of exactly one hour between them, that gap can be anywhere from zero to two hours on a bad day. Build tolerance into any logic that depends on recency.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deleting before creating: the duplicate trap
&lt;/h2&gt;

&lt;p&gt;Every call to ScriptApp.newTrigger(...).create() registers a new trigger entry, regardless of whether one already exists for the same function. Re-running your setup function — say, after tweaking the hour — stacks a second trigger on top of the first. After three iterations you have three daily runs, and the Triggers dashboard in the Apps Script editor is the only place that shows you this.&lt;/p&gt;

&lt;p&gt;The fix is to delete any existing triggers for the target function before creating a new one, which is what the loop in the snippet above does. getHandlerFunction() returns the string name you passed to newTrigger(), so matching on 'myDailyJob' is exact.&lt;/p&gt;

&lt;p&gt;A common mistake is calling the setup function from another trigger — say, a daily cleanup job that reinitializes everything. That creates a new trigger every day while the previous one persists. createDailyTrigger() should be called exactly once, manually, from the editor's Run menu.&lt;/p&gt;

&lt;h2&gt;
  
  
  Authorization and the first-run prompt
&lt;/h2&gt;

&lt;p&gt;Time-based triggers run as the user who authorized the script. When you run createDailyTrigger() for the first time, Apps Script will prompt for OAuth consent if the script hasn't been authorized yet. You must complete that flow in the editor; the trigger will not run until authorization is granted.&lt;/p&gt;

&lt;p&gt;If you later revoke access (via myaccount.google.com/permissions) or the OAuth token expires due to a password change or policy rotation, the trigger silently fails. You will see a failure email from &lt;a href="mailto:apps-scripts-notify@google.com"&gt;apps-scripts-notify@google.com&lt;/a&gt; with a vague 'Authorization is required' message. The fix is to open the script, run any function manually to re-trigger the consent flow, and re-authorize.&lt;/p&gt;

&lt;p&gt;Scripts attached to a Workspace account (G Suite / Google Workspace) may also hit domain administrator restrictions on which OAuth scopes are allowed. If your trigger stops firing after an org policy change, check the Admin console under Security &amp;gt; API controls before assuming the script is broken.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why did my script run twice today?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You have duplicate triggers. Open the Apps Script editor, click the clock icon (Triggers) in the left sidebar, and look for multiple entries pointing to the same handler function. Delete all but one. This happens when you run the setup function more than once without deleting the existing trigger first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I make atHour fire at exactly 8:15 instead of somewhere in the 8 AM window?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. atHour(8) gives you a one-hour window; Apps Script offers no minute-level precision for time-based triggers. For exact timing you need Cloud Scheduler (cron syntax, minute precision) calling an Apps Script deployment via its web app URL, or a Cloud Function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The trigger is listed but my function never runs — what's wrong?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Check three things in order: (1) the function name string passed to newTrigger() matches the actual function name exactly, case included; (2) the script is authorized — run it manually once to confirm no auth prompt appears; (3) look at the trigger execution log (Executions in the left sidebar) for error messages, which are often more specific than the failure email.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I set the timezone the trigger uses?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the Apps Script editor, go to Project Settings (gear icon) and set the Time zone field. This is a per-project setting and affects all time-based triggers in that script. It is separate from the spreadsheet's locale timezone if this script is container-bound to a Sheet.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Want the plain-English version? Describe the automation at &lt;a href="https://bulldo.gs/run-a-script-every-day-at-a-set-time-apps-script" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; and get working Apps Script back — free, no login.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googleappsscript</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Grade a quiz with a script in Google Forms</title>
      <dc:creator>bulldo.gs</dc:creator>
      <pubDate>Sat, 13 Jun 2026 00:50:53 +0000</pubDate>
      <link>https://dev.to/bulldo_gs/grade-a-quiz-with-a-script-in-google-forms-2n4c</link>
      <guid>https://dev.to/bulldo_gs/grade-a-quiz-with-a-script-in-google-forms-2n4c</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally written for &lt;a href="https://bulldo.gs/grade-a-quiz-with-a-script-in-google-forms" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; — republished here with the canonical link pointing home.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I want to write an Apps Script that reads quiz responses and sets scores or feedback automatically, but nothing seems to save.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Grade all responses for a Quiz-mode Google Form&lt;/span&gt;
&lt;span class="c1"&gt;// Run manually or wire to an onFormSubmit trigger&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;gradeAllResponses&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;FormApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getActiveForm&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;responses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getResponses&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;responses&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;itemResponses&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getGradableItemResponses&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;itemResponses&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;itemResponses&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;correct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getItem&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;asMultipleChoiceItem&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getChoices&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;points&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getResponse&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;correct&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;getValue&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setScore&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;points&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;item&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setFeedback&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="nx"&gt;FormApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createFeedback&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;setText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
          &lt;span class="nx"&gt;points&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Correct.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Review section 3.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
        &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;build&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
      &lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;

    &lt;span class="nx"&gt;form&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;submitGrades&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Quiz mode is a prerequisite, not an assumption
&lt;/h2&gt;

&lt;p&gt;Before any grading script can write scores, the form itself must be in Quiz mode. Open the form in the editor, go to Settings, and confirm the 'Make this a quiz' toggle is on. Without it, getGradableItemResponses() returns an empty array on every response and your script exits silently with nothing to show for it.&lt;/p&gt;

&lt;p&gt;With Quiz mode on, each item that carries a point value becomes a 'gradable' item. The script calls getGradableItemResponses() rather than getItemResponses() because only the gradable variant exposes setScore() and setFeedback(). The two methods look similar in the docs; the wrong one compiles fine and does nothing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why setScore() alone is not enough
&lt;/h2&gt;

&lt;p&gt;This is the part that bites almost everyone the first time. Calling item.setScore(10) and item.setFeedback(...) mutates the in-memory ItemResponse object. It does not write anything to Forms' backend. You can loop through every response, set every score, log 'done', and the respondent's score panel will be completely blank.&lt;/p&gt;

&lt;p&gt;The write happens when you call form.submitGrades([response]) with the modified FormResponse passed in as an array. That single call is what flushes all the setScore() and setFeedback() changes you accumulated on that response. I kept this in a utils file comment for months after I first hit it, because the name submitGrades implies a submit action rather than a save action and nothing in the error output tells you the data was silently dropped.&lt;/p&gt;

&lt;p&gt;One practical consequence: call submitGrades once per response, after you have finished mutating all its item responses. Calling it inside the inner loop (once per item) works but burns quota faster and is unnecessary.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring to a trigger for real-time grading
&lt;/h2&gt;

&lt;p&gt;Running gradeAllResponses() manually is fine for a one-off batch, but for a live quiz you want the function to fire each time a new response arrives. In the Apps Script editor, go to Triggers, add a new trigger, set the event source to 'From form' and the event type to 'On form submit'.&lt;/p&gt;

&lt;p&gt;The trigger passes an event object with a response property, so you can skip the getResponses() loop entirely and grade only the single new submission. Replace the function signature with gradeAllResponses(e), grab the response via e.response, and call submitGrades([e.response]) after scoring. This keeps execution time well under the 30-second Apps Script trigger limit even on long quizzes.&lt;/p&gt;

&lt;p&gt;One gotcha: the trigger runs under the account that installed it. If that account loses edit access to the form, grading stops silently. Worth noting in your team's runbook.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;My script runs without errors but scores are still blank in the response view. What am I missing?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You skipped form.submitGrades([response]). Calling setScore() and setFeedback() only modifies the in-memory object; submitGrades() is the call that actually persists the changes to Forms' backend. Add it once per response, after all item mutations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use getItemResponses() instead of getGradableItemResponses()?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. getItemResponses() returns ItemResponse objects, which do not have setScore() or setFeedback() methods. You need getGradableItemResponses() to get GradableItemResponse objects. Both methods exist on FormResponse, which is why the confusion is easy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does submitGrades() send an email to the respondent?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Only if the form is configured to release scores via email and the respondent chose to receive them. submitGrades() persists the grade data; the email release is a separate form-level setting under Quiz settings &amp;gt; Release grade.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I grade open-ended or paragraph questions with a script?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. getParagraphTextItem() questions are gradable if the form is in Quiz mode; you call the same setScore() and setFeedback() methods on their responses. The script has to implement its own comparison logic since there is no built-in correct-answer check for free-text items.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Want the plain-English version? Describe the automation at &lt;a href="https://bulldo.gs/grade-a-quiz-with-a-script-in-google-forms" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; and get working Apps Script back — free, no login.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googleappsscript</category>
      <category>javascript</category>
      <category>googleforms</category>
    </item>
    <item>
      <title>Replace text placeholders across a deck in Google Slides</title>
      <dc:creator>bulldo.gs</dc:creator>
      <pubDate>Sat, 13 Jun 2026 00:45:42 +0000</pubDate>
      <link>https://dev.to/bulldo_gs/replace-text-placeholders-across-a-deck-in-google-slides-48o6</link>
      <guid>https://dev.to/bulldo_gs/replace-text-placeholders-across-a-deck-in-google-slides-48o6</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally written for &lt;a href="https://bulldo.gs/replace-text-placeholders-across-a-deck-in-google-slides" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; — republished here with the canonical link pointing home.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I need to fill a Google Slides template with dynamic values (names, dates, company fields) without manually editing every text box on every slide.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Replace {{token}} placeholders across an entire Slides deck.&lt;/span&gt;
&lt;span class="c1"&gt;// matchCase = true prevents {{name}} from also clobbering {{full_name}}.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fillDeckFromData&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;DECK_ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;1aBcDeFgHiJkLmNoPqRsTuVwXyZ&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// replace with your deck ID&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;pres&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SlidesApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;DECK_ID&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;replacements&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{{first_name}}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Maria&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{{company}}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Acme Corp&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{{date}}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;June 12, 2026&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;{{tier}}&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;       &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Professional&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;};&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="k"&gt;in&lt;/span&gt; &lt;span class="nx"&gt;replacements&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;pres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replaceAllText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;replacements&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="nx"&gt;pres&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;saveAndClose&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The matchCase trap that ruins your template
&lt;/h2&gt;

&lt;p&gt;Presentation.replaceAllText(searchText, replacement) accepts an optional third argument, matchCase. It defaults to false. That sounds harmless until you have both {{name}} and {{full_name}} in the same deck: a case-insensitive search for {{name}} will match inside {{full_name}} first, leaving you with a garbled token like {{fullMaria}} on slide 3.&lt;/p&gt;

&lt;p&gt;Passing true as the third argument locks the search to an exact byte-for-byte match. Combined with a consistent delimiter convention — double curly braces, lowercase, underscores for spaces — your tokens never collide with real slide copy. The first time I hit the garbled-token bug it was in a 40-slide sales deck sent to a live prospect; the fix is one boolean.&lt;/p&gt;

&lt;p&gt;Choose your delimiter once and enforce it in the template. {{first_name}} is unambiguous; name or FIRST_NAME without delimiters will eventually match something in a headline and you will not catch it until after the send.&lt;/p&gt;

&lt;h2&gt;
  
  
  How replaceAllText() reaches every shape
&lt;/h2&gt;

&lt;p&gt;The method operates at the Presentation level, not the Slide level. One call walks every slide, every layout, and every master in the deck — including text inside grouped shapes, tables, and speaker notes. You do not need to loop over slides yourself.&lt;/p&gt;

&lt;p&gt;The replacement is applied to the raw text run content. It does not touch font size, color, or paragraph style on the replaced span, which means your template's formatting survives the substitution intact. If you need to change formatting on replaced text (bold a name, colorize a tier label), you have to iterate shapes manually after the fact using Shape.getText().getRuns().&lt;/p&gt;

&lt;p&gt;saveAndClose() at the end flushes the changes and releases the lock. If you omit it, changes are still written on script exit, but calling it explicitly is the safe pattern for any script that touches a shared file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Driving it from a Sheet row
&lt;/h2&gt;

&lt;p&gt;The common production pattern is one Sheet row per recipient: columns map to token names, and the script reads the active row, builds the replacements object, makes a copy of the master deck with DriveApp.getFileById(DECK_ID).makeCopy(recipientName), fills the copy, then exports or shares it.&lt;/p&gt;

&lt;p&gt;DriveApp.makeCopy() returns a File object; call getId() on it to get the deck ID you pass into SlidesApp.openById(). Keep the master deck untouched by always working on the copy — replaceAllText() is destructive and there is no undo inside Apps Script.&lt;/p&gt;

&lt;p&gt;If you are generating more than 20 or 30 decks in a single run, wrap the loop in a try/catch and log failures to a second sheet. The Slides API has a per-minute quota (currently 300 write requests per minute per project); a tight loop over a large list will hit it and the default error message does not tell you which row failed.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does replaceAllText() work inside tables and speaker notes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The method scans all text content in the presentation, including table cells and speaker notes. Shapes inside groups are also included. The one place it does not reach is alt text (accessibility descriptions) — those are not text runs and require a separate API call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My placeholder is not being replaced even though it looks right in the slide.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Google Slides sometimes splits a single visible token across multiple text runs internally, especially if you typed it character-by-character or edited it later. Open the slide, delete the token, and retype it in one pass without clicking away. You can also diagnose this by calling Shape.getText().getRuns() in a helper script and logging each run's content — you will see the token split across two or three entries.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I replace a placeholder with formatted text (bold, a different color)?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No, not with replaceAllText(). The method only substitutes the string content; it cannot inject a formatted span. The workaround is to replace with a unique sentinel string, then iterate shapes to find that sentinel with getText().find(), and apply formatting to the returned TextRange.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I target only specific slides instead of the whole deck?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;replaceAllText() has no slide-range filter. To target specific slides, skip the Presentation-level method and instead call getSlides(), filter the array to the slides you want, then loop over each slide's shapes and call Shape.getText().replaceAllText() on each shape individually. The shape-level variant takes the same (searchText, replacement, matchCase) signature.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Want the plain-English version? Describe the automation at &lt;a href="https://bulldo.gs/replace-text-placeholders-across-a-deck-in-google-slides" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; and get working Apps Script back — free, no login.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googleappsscript</category>
      <category>javascript</category>
      <category>googleslides</category>
    </item>
    <item>
      <title>Convert getValues for loops to map and filter</title>
      <dc:creator>bulldo.gs</dc:creator>
      <pubDate>Sat, 13 Jun 2026 00:45:36 +0000</pubDate>
      <link>https://dev.to/bulldo_gs/convert-getvalues-for-loops-to-map-and-filter-1eln</link>
      <guid>https://dev.to/bulldo_gs/convert-getvalues-for-loops-to-map-and-filter-1eln</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally written for &lt;a href="https://bulldo.gs/convert-getvalues-loops-to-map-and-filter-apps-script" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; — republished here with the canonical link pointing home.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I want to rewrite my getValues for loop using map and filter so the code is shorter, but I keep getting a parameter mismatch error when I try to write results back with setValues.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Keep only rows where column A &amp;gt; 100, then write column B values&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;filterAndRemap&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SpreadsheetApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getActiveSheet&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLastRow&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getValues&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="c1"&gt;// filter keeps the full row -- 2D shape intact&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;filtered&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="c1"&gt;// map over filtered rows to extract column B, wrapped back into arrays&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;colB&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;filtered&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;row&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]];&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;colB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;colB&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;colB&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What getValues actually gives you
&lt;/h2&gt;

&lt;p&gt;getValues returns a 2D array: an array of rows, where each row is itself an array of cell values. A three-row, two-column range comes back as [[A1,B1],[A2,B2],[A3,B3]]. That 2D shape is not optional -- setValues requires it, and flattening it (even accidentally) triggers the parameter mismatch error: 'The number of rows in the data does not match the number of rows in the range.'&lt;/p&gt;

&lt;p&gt;The classic for loop sidesteps this by never changing the shape at all; it just reads and writes by index. When you switch to map and filter you start transforming the array, which is where the shape problem surfaces.&lt;/p&gt;

&lt;h2&gt;
  
  
  filter rows first, then map values -- in that order
&lt;/h2&gt;

&lt;p&gt;filter is safe because it returns a subset of the original rows unchanged. Each surviving element is still a full array, so the outer array remains 2D. You lose nothing by running filter before any other transformation.&lt;/p&gt;

&lt;p&gt;map is where people get burned. Calling data.map(function(row) { return row[0]; }) produces a 1D array of scalars -- fine for reading, fatal for setValues. The fix is to wrap the extracted value back into a one-element array: return [row[0]]. That single extra pair of brackets restores the 2D shape setValues needs.&lt;/p&gt;

&lt;p&gt;The first time I hit this I spent twenty minutes staring at what looked like a perfectly correct range reference before realizing the column count was 1 but the data depth was 0. The guard at the end (checking colB.length before calling setValues) is also worth keeping: calling setValues on a zero-row range throws a separate error that is even harder to read.&lt;/p&gt;

&lt;h2&gt;
  
  
  Chaining without a temp variable
&lt;/h2&gt;

&lt;p&gt;Once the filter-then-map order is clear, you can chain both calls directly off getValues and skip the intermediate variable. That cuts a five-to-eight line for loop down to three lines of actual logic, with no index arithmetic and no off-by-one risk on the loop bound.&lt;/p&gt;

&lt;p&gt;One real tradeoff: chaining obscures the intermediate shape for anyone reading the code later. If the filter condition is at all complicated, keeping filter and map as separate named variables (as in the snippet above) makes the intent obvious at a glance. For a trivial condition like row[0] &amp;gt; 100, the chain is fine. Use judgment rather than a rule.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does map(function(row) { return row[0]; }) cause a setValues error?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That map produces a 1D array of scalars, like [10, 20, 30]. setValues requires a 2D array where every element is itself an array. Wrap the value: return [row[0]], which gives [[10],[20],[30]] -- the shape setValues accepts.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I filter and map in a single pass instead of two steps?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You can use reduce to do both at once, but Apps Script does not have flatMap and reduce with conditional push is harder to read than a chained filter().map(). Two steps is idiomatic and the performance difference over a sheet-sized dataset (a few thousand rows) is not measurable.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work if I need to keep multiple columns, not just one?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. In the map callback, return all the columns you want as an array literal: return [row[1], row[2], row[4]]. The length of that inner array must match the column count you pass to getRange when you call setValues.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What happens if filter removes all rows and colB is empty?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;setValues on an empty array throws 'Incorrect range height'. Guard with if (colB.length &amp;gt; 0) before the write, as shown in the snippet. Alternatively, clear the destination range first so no stale values remain when the filter result is zero rows.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Want the plain-English version? Describe the automation at &lt;a href="https://bulldo.gs/convert-getvalues-loops-to-map-and-filter-apps-script" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; and get working Apps Script back — free, no login.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googleappsscript</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Switch an old script from Rhino to the V8 runtime</title>
      <dc:creator>bulldo.gs</dc:creator>
      <pubDate>Sat, 13 Jun 2026 00:40:25 +0000</pubDate>
      <link>https://dev.to/bulldo_gs/switch-an-old-script-from-rhino-to-the-v8-runtime-4oej</link>
      <guid>https://dev.to/bulldo_gs/switch-an-old-script-from-rhino-to-the-v8-runtime-4oej</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally written for &lt;a href="https://bulldo.gs/switch-from-rhino-to-v8-runtime-apps-script" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; — republished here with the canonical link pointing home.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I want to enable the V8 runtime on an existing Apps Script project so I can use modern JavaScript, but I am worried about breaking things that are already working.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// appsscript.json — set runtimeVersion to enable V8&lt;/span&gt;
&lt;span class="c1"&gt;// Rollback: change V8 back to DEPRECATED_ES5&lt;/span&gt;
&lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;timeZone&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;America/New_York&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;dependencies&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;exceptionLogging&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;STACKDRIVER&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;runtimeVersion&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;V8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="c1"&gt;// Code.gs — safe V8-compatible replacement for a common Rhino pattern&lt;/span&gt;
&lt;span class="c1"&gt;// Rhino allowed: for each (var item in collection) {}&lt;/span&gt;
&lt;span class="c1"&gt;// V8 requires standard for...of instead&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;listSheetNames&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;ss&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SpreadsheetApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getActiveSpreadsheet&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;names&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;sheets&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ss&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSheets&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;sheets&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sheets&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;getName&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;Logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;names&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The one-line change and why it is a project-wide bomb
&lt;/h2&gt;

&lt;p&gt;Open the Apps Script editor, click Project Settings (the gear icon), and check "Show appsscript.json manifest file in editor." Then open that file and change &lt;code&gt;"runtimeVersion": "DEPRECATED_ES5"&lt;/code&gt; to &lt;code&gt;"runtimeVersion": "V8"&lt;/code&gt;. Save. That is the entire migration from a settings standpoint.&lt;/p&gt;

&lt;p&gt;What catches people off guard is the failure mode. V8 parses every .gs file in the project as a unit before running anything. One Rhino-only statement — a &lt;code&gt;for each&lt;/code&gt; loop, a &lt;code&gt;__iterator__&lt;/code&gt; method, a &lt;code&gt;Date.prototype.getYear&lt;/code&gt; call in an otherwise untouched utility file — causes a syntax or runtime error that prevents the whole script from initializing. Not just the file that contains the bad line. Every function, every trigger, the entire project goes dark.&lt;/p&gt;

&lt;p&gt;The first time I hit this it took me twenty minutes to figure out why a completely unrelated trigger had stopped firing. The error message pointed at the Rhino syntax in a helper file I had not touched in two years. V8 does not isolate the damage; it fails at parse time, before any execution happens.&lt;/p&gt;

&lt;h2&gt;
  
  
  Finding Rhino-only syntax before you flip the switch
&lt;/h2&gt;

&lt;p&gt;The three patterns that break most often under V8 are: &lt;code&gt;for each (var x in obj)&lt;/code&gt; (a Mozilla-specific extension Rhino supported, not standard ES), XML literals like &lt;code&gt;var x = &amp;lt;tag/&amp;gt;&lt;/code&gt;, and &lt;code&gt;__iterator__&lt;/code&gt; or &lt;code&gt;__defineGetter__&lt;/code&gt; on prototypes. Do a text search across all .gs files for those strings before you change runtimeVersion.&lt;/p&gt;

&lt;p&gt;The Apps Script editor has no project-wide search, so either use the browser's find-in-page on each file, or copy all your .gs files into a local editor with multi-file search. Any hit needs to be rewritten: &lt;code&gt;for each&lt;/code&gt; becomes a standard &lt;code&gt;for...of&lt;/code&gt; or indexed &lt;code&gt;for&lt;/code&gt; loop; XML literals need to be replaced with &lt;code&gt;XmlService&lt;/code&gt; calls or plain strings; the prototype methods have no direct replacement and usually need a logic rewrite.&lt;/p&gt;

&lt;p&gt;After fixing the files, change &lt;code&gt;runtimeVersion&lt;/code&gt; to &lt;code&gt;V8&lt;/code&gt;, save, and immediately run one function manually from the editor toolbar. If the Execution Log shows the function completing, your triggers are also alive. If you see a red syntax error, V8 caught something the search missed — read the error, fix the line, repeat.&lt;/p&gt;

&lt;h2&gt;
  
  
  Instant rollback while you keep fixing files
&lt;/h2&gt;

&lt;p&gt;If production triggers are firing and you need to un-break them immediately, set &lt;code&gt;"runtimeVersion": "DEPRECATED_ES5"&lt;/code&gt; in appsscript.json and save. Rhino resumes within seconds. You do not need to revert any .gs file changes you already made — DEPRECATED_ES5 tolerates both old and new syntax.&lt;/p&gt;

&lt;p&gt;This lets you migrate file by file. Fix one .gs file, flip to V8, test, flip back to DEPRECATED_ES5, fix the next file. Tedious for large projects, but it keeps triggers running throughout. Once every file passes a V8 test run, leave runtimeVersion set to V8 permanently.&lt;/p&gt;

&lt;p&gt;One practical note: the &lt;code&gt;DEPRECATED_ES5&lt;/code&gt; value is exactly that — deprecated. Google has not announced a hard removal date as of mid-2026, but they have signaled V8 is the intended path. Old scripts will keep running on Rhino for now, but new V8-only APIs (like &lt;code&gt;globalThis&lt;/code&gt; or native &lt;code&gt;Promise&lt;/code&gt; support in triggers) are not back-ported, so staying on Rhino indefinitely means foregoing those.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;After I set runtimeVersion to V8, all my triggers stopped running with no error in the dashboard. What happened?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;A parse-time error in any .gs file silently kills initialization. Open the editor, run any function manually, and look at the Execution Log — it will show the actual syntax error and the file name. Fix that line, then triggers resume automatically without redeployment.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I use arrow functions and const/let after switching to V8?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. V8 supports ES2019 and most ES2020 syntax: arrow functions, const/let, destructuring, spread, async/await, optional chaining, and nullish coalescing. The one thing that does not work the same way is top-level await — Apps Script still requires async functions to be called from a synchronous trigger wrapper.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My script uses XmlService and it works fine on Rhino. Will it break on V8?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;XmlService itself is a Google service and is runtime-agnostic — it works on both. What breaks under V8 is E4X, the old Rhino XML literal syntax (angle-bracket XML directly in JavaScript code). If your script uses XmlService.parse() and XmlService.getContentType() style calls rather than inline XML literals, it will migrate without changes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Is there a way to see which runtime a deployed script is currently using without opening the manifest?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Not from outside the editor. You have to open the project, enable manifest display in Project Settings, and read appsscript.json. The Apps Script API (the REST API for managing scripts) does expose the runtimeVersion field in the project Content resource if you need to audit it programmatically across many projects.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Want the plain-English version? Describe the automation at &lt;a href="https://bulldo.gs/switch-from-rhino-to-v8-runtime-apps-script" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; and get working Apps Script back — free, no login.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googleappsscript</category>
      <category>javascript</category>
    </item>
    <item>
      <title>Fix "Exceeded maximum execution time" in Apps Script</title>
      <dc:creator>bulldo.gs</dc:creator>
      <pubDate>Sat, 13 Jun 2026 00:40:19 +0000</pubDate>
      <link>https://dev.to/bulldo_gs/fix-exceeded-maximum-execution-time-in-apps-script-4ki3</link>
      <guid>https://dev.to/bulldo_gs/fix-exceeded-maximum-execution-time-in-apps-script-4ki3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally written for &lt;a href="https://bulldo.gs/fix-exceeded-maximum-execution-time-apps-script" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; — republished here with the canonical link pointing home.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I'm running a script that processes a large spreadsheet and it keeps dying with "Exceeded maximum execution time" before it finishes.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Checkpoint-resume pattern for long-running sheet jobs&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;processInBatches&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;props&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;PropertiesService&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getScriptProperties&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;startRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;parseInt&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lastRow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SpreadsheetApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getActiveSpreadsheet&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getActiveSheet&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;lastDataRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getLastRow&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;BATCH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;SAFE_MS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;60&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;endRow&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;Math&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startRow&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;BATCH&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;lastDataRow&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startRow&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;endRow&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;startRow&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getValues&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="nx"&gt;started&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;SAFE_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lastRow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;startRow&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="c1"&gt;// process data[i] here&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endRow&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;lastDataRow&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;deleteProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lastRow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nf"&gt;deleteTrigger_&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;props&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setProperty&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;lastRow&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;endRow&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The 6-minute wall is per-execution, not per-task
&lt;/h2&gt;

&lt;p&gt;Apps Script enforces a hard 6-minute execution time limit per run, regardless of whether you're on a free account or a Workspace account (which bumps the limit to 30 minutes, but the same cliff exists). The error doesn't mean your logic is wrong; it means one continuous call to your function took too long. The fix is to stop thinking of your job as a single execution and start thinking of it as a pipeline of short runs.&lt;/p&gt;

&lt;p&gt;The first time I hit this, I wasted an afternoon trying to speed up the loop. Marginal gains didn't move the needle because the data volume was the real problem — 4,000 rows at one Sheets API call per row will always breach 6 minutes. The correct frame is: how do I save where I stopped and pick up there next run?&lt;/p&gt;

&lt;h2&gt;
  
  
  Saving and restoring a cursor with PropertiesService
&lt;/h2&gt;

&lt;p&gt;PropertiesService.getScriptProperties() gives you a small key-value store that persists between executions (up to 9 KB total, 500 bytes per value). Store the last-processed row number there before you return. On the next run, read it back and start from that row. When the job finishes, delete the property so subsequent manual runs start from row 2 again.&lt;/p&gt;

&lt;p&gt;The safety check in the snippet above — comparing Date.now() minus the start time against 5 minutes (SAFE_MS) — gives the function 60 seconds of headroom before the hard kill. Without that buffer, the process can be mid-write when the runtime terminates it, leaving a corrupt checkpoint. Set SAFE_MS to 5 * 60 * 1000 and you will never lose a checkpoint to a race condition.&lt;/p&gt;

&lt;p&gt;To keep the job moving automatically, install a time-driven trigger pointing at the same function: ScriptApp.newTrigger('processInBatches').timeBased().everyMinutes(1).create(). The function runs every minute, advances the cursor, and returns early when time is short. Once the last row is reached, the cleanup branch calls deleteTrigger_() — a small helper that finds and deletes the trigger by handler name — so the job doesn't loop forever.&lt;/p&gt;

&lt;h2&gt;
  
  
  Batch your reads first — it often eliminates the problem
&lt;/h2&gt;

&lt;p&gt;Before wiring up checkpointing, check whether your script is calling getValue() or getRange().getValue() inside a loop. Each individual call to the Sheets API costs roughly 50–100 ms in round-trip overhead. 500 rows at one call each is already 25–50 seconds; 3,000 rows blows past 6 minutes without a single line of real work done.&lt;/p&gt;

&lt;p&gt;The fix is one getValues() call outside the loop: sheet.getRange(startRow, 1, rowCount, columnCount).getValues() returns a 2D array you can iterate at memory speed. In practice, switching from per-row getValue() to a single getValues() on 2,000 rows drops execution time from 3–4 minutes to under 10 seconds. That alone closes the issue for most scripts.&lt;/p&gt;

&lt;p&gt;The snippet batches reads to 200 rows at a time partly as a defensive ceiling — even with getValues(), very wide sheets with complex data can be slow — and partly to keep each execution well inside the safe zone. Tune BATCH upward if your sheet is narrow; 500 is a safe ceiling for a 10-column sheet.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does the 6-minute limit reset if I call Utilities.sleep()?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No. sleep() counts against your execution time the same as any other instruction. Sleeping inside a single execution to avoid rate limits still burns the clock. The only way to get more wall-clock time is to end the execution and schedule a new one via a time-driven trigger.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Will a Workspace (paid) account give me more than 6 minutes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes — Workspace accounts get a 30-minute limit per execution. But the same cliff exists; a job processing tens of thousands of rows will still hit it. The checkpoint pattern works identically at 30 minutes; just set SAFE_MS to 28 * 60 * 1000.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I delete the trigger when the job finishes?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Iterate ScriptApp.getProjectTriggers(), check trigger.getHandlerFunction() === 'processInBatches', and call ScriptApp.deleteTrigger(trigger). Wrap this in a private helper function named deleteTrigger_ (the underscore makes it uncallable from the UI). Failing to delete it means the function keeps firing every minute indefinitely, which burns your daily trigger quota (20 trigger-hours/day for free accounts).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My script writes results back to the sheet — how do I avoid writing duplicates on resume?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Write to the same row index you read from. Because startRow is restored from the checkpoint before any reads or writes happen, the output range for a given run starts exactly where the last run stopped. There is no window where a row gets processed twice, as long as you update the checkpoint before returning and never update it after a write failure.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Want the plain-English version? Describe the automation at &lt;a href="https://bulldo.gs/fix-exceeded-maximum-execution-time-apps-script" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; and get working Apps Script back — free, no login.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googleappsscript</category>
      <category>javascript</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Merge multiple docs into one in Google Docs</title>
      <dc:creator>bulldo.gs</dc:creator>
      <pubDate>Sat, 13 Jun 2026 00:35:08 +0000</pubDate>
      <link>https://dev.to/bulldo_gs/merge-multiple-docs-into-one-in-google-docs-3o10</link>
      <guid>https://dev.to/bulldo_gs/merge-multiple-docs-into-one-in-google-docs-3o10</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally written for &lt;a href="https://bulldo.gs/merge-multiple-docs-into-one-in-google-docs" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; — republished here with the canonical link pointing home.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I want to programmatically combine several Google Docs into one file without losing tables or list formatting.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Merges all source docs into destDocId, in order.&lt;/span&gt;
&lt;span class="c1"&gt;// Run from the Apps Script editor; no triggers needed.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;mergeDocs&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;sourceIds&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DOC_ID_ONE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DOC_ID_TWO&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DOC_ID_THREE&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;
  &lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;dest&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DocumentApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;DEST_DOC_ID&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;getBody&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;sourceIds&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;srcBody&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DocumentApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;openById&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sourceIds&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]).&lt;/span&gt;&lt;span class="nf"&gt;getBody&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;srcBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getNumChildren&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;total&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;srcBody&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getChild&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getType&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;DocumentApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ElementType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;PARAGRAPH&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendParagraph&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asParagraph&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;DocumentApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ElementType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;TABLE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendTable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asTable&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;type&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;DocumentApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ElementType&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;LIST_ITEM&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="nx"&gt;dest&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;appendListItem&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;asListItem&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;copy&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why there is no single appendElement call
&lt;/h2&gt;

&lt;p&gt;The Document service in Apps Script does not expose a generic &lt;code&gt;appendElement&lt;/code&gt; method on &lt;code&gt;Body&lt;/code&gt;. Every element type has its own typed append method: &lt;code&gt;appendParagraph&lt;/code&gt;, &lt;code&gt;appendTable&lt;/code&gt;, &lt;code&gt;appendListItem&lt;/code&gt;, and so on. That means a merge loop that ignores element types will throw &lt;code&gt;TypeError: el.copy is not a function&lt;/code&gt; the moment it hits a table, because you would be passing an &lt;code&gt;Element&lt;/code&gt; where the API expects a &lt;code&gt;Table&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The fix is to call &lt;code&gt;getType()&lt;/code&gt; on each child element and switch on &lt;code&gt;DocumentApp.ElementType&lt;/code&gt;. The type enum values are strings like &lt;code&gt;PARAGRAPH&lt;/code&gt;, &lt;code&gt;TABLE&lt;/code&gt;, &lt;code&gt;LIST_ITEM&lt;/code&gt;, &lt;code&gt;INLINE_IMAGE&lt;/code&gt;, and &lt;code&gt;HORIZONTAL_RULE&lt;/code&gt;. In practice the first three account for almost all real document content. The code above handles those three and silently skips anything else (images, rules) rather than crashing the entire merge.&lt;/p&gt;

&lt;h2&gt;
  
  
  Getting the doc IDs and running the script
&lt;/h2&gt;

&lt;p&gt;The ID for any Google Doc is the long string between &lt;code&gt;/d/&lt;/code&gt; and &lt;code&gt;/edit&lt;/code&gt; in its URL. Paste those into the &lt;code&gt;sourceIds&lt;/code&gt; array and put the destination doc's ID in &lt;code&gt;'DEST_DOC_ID'&lt;/code&gt;. The destination doc must already exist; the script does not create it.&lt;/p&gt;

&lt;p&gt;Open &lt;code&gt;script.google.com&lt;/code&gt;, create a new project, paste the function, save, and click Run. The first run will trigger an OAuth consent screen asking for permission to read and modify your Docs. Accept it. Subsequent runs execute without prompting. I keep this script bound to the destination doc (via Extensions &amp;gt; Apps Script from within that doc) so the project stays easy to find later.&lt;/p&gt;

&lt;p&gt;One quota note: &lt;code&gt;DocumentApp.openById&lt;/code&gt; counts against the Apps Script read quota, which is 50 opens per execution for consumer accounts and higher for Workspace. For more than roughly 40 source docs, batch across multiple executions or use a time-driven trigger that processes a slice at a time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Preserving list grouping across source documents
&lt;/h2&gt;

&lt;p&gt;List items in Google Docs carry a list ID that ties them to a specific list group. When you copy a &lt;code&gt;ListItem&lt;/code&gt; from one document into another, Apps Script assigns it a new list ID automatically, so items from different source docs will not accidentally merge into the same numbered list. That behavior is what you want.&lt;/p&gt;

&lt;p&gt;Where it surprises people: if a single source doc has two separate bullet lists separated by a paragraph, and that paragraph is a heading (which Apps Script still sees as &lt;code&gt;PARAGRAPH&lt;/code&gt;), the heading copies correctly and the two lists stay separate. The problem only appears if you skip paragraph elements, which drops the visual boundary and makes two distinct lists appear to run together in the output. Copy everything in child order and this does not happen.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Does this preserve bold, italic, and heading styles?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. The &lt;code&gt;.copy()&lt;/code&gt; call on a paragraph or list item carries over all inline text attributes (bold, italic, underline, font size) and the paragraph style (Heading 1, Normal, etc.). What it does not carry over is named styles you defined via a custom Style; those stay tied to the source document's stylesheet.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Why does my table appear empty after the merge?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;You likely called &lt;code&gt;appendTable&lt;/code&gt; with the raw &lt;code&gt;Element&lt;/code&gt; object rather than casting it first with &lt;code&gt;el.asTable()&lt;/code&gt;. The cast returns a proper &lt;code&gt;Table&lt;/code&gt; instance whose &lt;code&gt;.copy()&lt;/code&gt; includes all rows and cells. Skipping the cast passes an untyped element and the API creates an empty table shell.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I merge docs from a shared drive I do not own?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, as long as the account running the script has at least Viewer access on each source doc. The OAuth scope &lt;code&gt;https://www.googleapis.com/auth/documents&lt;/code&gt; covers cross-ownership reads. If a source doc is restricted and the script account lacks access, &lt;code&gt;openById&lt;/code&gt; throws a &lt;code&gt;GoogleJsonResponseException&lt;/code&gt; with a 403; fix it by sharing the source doc with the script-runner's account.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The script stops after a few docs with a 'Service invoked too many times' error.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That is the Apps Script DocumentService quota cap. For consumer (free) Google accounts, the daily limit is 250 document opens. For Google Workspace accounts it is higher but still finite. Split your source IDs into batches of 20-30 and run the function multiple times, or set a time-driven trigger that processes one batch per minute.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Want the plain-English version? Describe the automation at &lt;a href="https://bulldo.gs/merge-multiple-docs-into-one-in-google-docs" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; and get working Apps Script back — free, no login.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googleappsscript</category>
      <category>javascript</category>
      <category>googledocs</category>
    </item>
    <item>
      <title>Import JSON from an API in Google Sheets</title>
      <dc:creator>bulldo.gs</dc:creator>
      <pubDate>Sat, 13 Jun 2026 00:35:02 +0000</pubDate>
      <link>https://dev.to/bulldo_gs/import-json-from-an-api-in-google-sheets-nm2</link>
      <guid>https://dev.to/bulldo_gs/import-json-from-an-api-in-google-sheets-nm2</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally written for &lt;a href="https://bulldo.gs/import-json-from-an-api-in-google-sheets" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; — republished here with the canonical link pointing home.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I want to pull live JSON data from an API endpoint directly into a Google Sheet without installing an add-on.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Fetch JSON from an API and write it to the active sheet&lt;/span&gt;
&lt;span class="c1"&gt;// Adjust API_URL and the field list to match your endpoint&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;importJsonFromApi&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;API_URL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://jsonplaceholder.typicode.com/users&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SpreadsheetApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getActiveSpreadsheet&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getActiveSheet&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;UrlFetchApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;API_URL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;response&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContentText&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;parse&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;id&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;name&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;username&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;email&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;phone&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;function&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearContents&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setValues&lt;/span&gt;&lt;span class="p"&gt;([&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
  &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why getContentText() comes before JSON.parse
&lt;/h2&gt;

&lt;p&gt;UrlFetchApp.fetch() returns an HTTPResponse object, not a string. The first time I skipped getContentText() and passed the response object directly to JSON.parse(), it silently parsed to null and the sheet wrote nothing. You need raw = response.getContentText() to get the actual body as a string, then JSON.parse(raw) turns it into a JavaScript object or array.&lt;/p&gt;

&lt;p&gt;The URL must be publicly accessible or accept an API key via a query parameter or Authorization header. Add headers like this: UrlFetchApp.fetch(url, { headers: { Authorization: 'Bearer ' + token } }). Apps Script's UrlFetchApp quota is 20,000 calls per day on a free Google account, 100,000 on Workspace.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rectangular array constraint — why setValues fails without mapping
&lt;/h2&gt;

&lt;p&gt;setValues() is strict: it requires a 2D array where every row has the same number of columns. If you hand it an array of plain JSON objects, it throws 'The number of rows or columns in the range does not match the number of rows or columns in the data.' The fix is to map each object to an array of values in a consistent column order, which is exactly what the headers.map() call inside data.map() does.&lt;/p&gt;

&lt;p&gt;Picking a fixed headers array also protects you when the API adds new fields — the sheet schema stays stable instead of silently growing extra columns. If a field is missing from a particular object, the fallback || '' keeps every row the same width so the range and the data never disagree.&lt;/p&gt;

&lt;p&gt;One practical note: if your API returns a single object rather than an array, wrap it: var data = [JSON.parse(raw)]. The map chain works identically.&lt;/p&gt;

&lt;h2&gt;
  
  
  Running it on a schedule without touching the sheet manually
&lt;/h2&gt;

&lt;p&gt;Open Extensions &amp;gt; Apps Script, paste the function, and save. To run it once, hit the play button (you'll be prompted to grant UrlFetchApp and SpreadsheetApp permissions on first run — both are required). To run it automatically, go to Triggers (the clock icon in the left sidebar), add a new trigger, choose importJsonFromApi, and set a time-based interval. Hourly or daily covers most data-freshness needs and stays well within the free quota.&lt;/p&gt;

&lt;p&gt;If the API requires OAuth rather than a static key, Apps Script's OAuth2 library (GitHub: googleworkspace/apps-script-oauth2) handles the token dance. That's a separate setup, but the fetch-parse-setValues pipeline here doesn't change.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why do I get 'Exception: Request failed' when fetching the URL?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Apps Script blocks URLs that return non-2xx status codes by default. Either the endpoint is down, requires authentication, or is returning a 4xx. Add { muteHttpExceptions: true } as the second argument to UrlFetchApp.fetch() to get the raw response body and status code back so you can see the actual error message.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I import JSON that has nested objects, like address.city?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes, but you have to flatten it manually. In the headers.map() callback, instead of obj[key], use a resolver like function getNestedValue(obj, path) that splits on '.' and walks the object. There is no built-in flattening — you decide which leaf values become columns.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The sheet only shows the first row of data. What went wrong?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The API probably returned a single object, not an array. JSON.parse on { ... } gives an object, and calling .map() on an object returns undefined. Wrap the parsed result: var data = Array.isArray(parsed) ? parsed : [parsed]. That normalizes both shapes.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I pass an API key without hardcoding it in the script?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Store it with PropertiesService: PropertiesService.getScriptProperties().setProperty('API_KEY', 'yourkey'). Read it back with PropertiesService.getScriptProperties().getProperty('API_KEY'). Properties are scoped to the script and not visible in version history, so they don't leak through shared spreadsheets or accidental screenshots of the editor.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Want the plain-English version? Describe the automation at &lt;a href="https://bulldo.gs/import-json-from-an-api-in-google-sheets" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; and get working Apps Script back — free, no login.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googleappsscript</category>
      <category>javascript</category>
      <category>googlesheets</category>
    </item>
    <item>
      <title>Send email reminders based on dates in Google Sheets</title>
      <dc:creator>bulldo.gs</dc:creator>
      <pubDate>Sat, 13 Jun 2026 00:29:50 +0000</pubDate>
      <link>https://dev.to/bulldo_gs/send-email-reminders-based-on-dates-in-google-sheets-58gj</link>
      <guid>https://dev.to/bulldo_gs/send-email-reminders-based-on-dates-in-google-sheets-58gj</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally written for &lt;a href="https://bulldo.gs/send-email-reminders-based-on-dates-in-google-sheets" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; — republished here with the canonical link pointing home.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I want to automatically send email reminders when a date in my Google Sheet matches today, but my comparisons keep firing a day early or not at all.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Run daily via time-driven trigger; col A = email, col B = due date, col C = task name&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendDateReminders&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SpreadsheetApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getActiveSpreadsheet&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getSheetByName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Tasks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDataRange&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getValues&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;dueDate&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="nx"&gt;dueDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;setHours&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;dueDate&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="nx"&gt;today&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getTime&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Reminder: &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; is due today&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;This is your reminder that "&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;" is due today.&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
      &lt;span class="nx"&gt;MailApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why date comparison almost always fires wrong
&lt;/h2&gt;

&lt;p&gt;When Apps Script reads a date cell via getValues(), it returns a JavaScript Date object. That object carries the full timestamp — including a time component that reflects the spreadsheet's timezone offset. A cell you formatted as '2026-06-10' arrives in your script as something like 'Wed Jun 10 2026 00:00:00 GMT-0500', and new Date() for the current moment might be 'Wed Jun 10 2026 09:14:33 GMT-0500'. Comparing those two with === or getTime() will always return false, so your reminder never fires.&lt;/p&gt;

&lt;p&gt;The fix is one line per date: call setHours(0, 0, 0, 0) on both the cell value and today's date before comparing. That zeroes out hours, minutes, seconds, and milliseconds, leaving only the calendar date. I keep a small normalizeDate() helper at the top of every sheet-automation file I write; re-typing setHours four times per comparison is where bugs hide.&lt;/p&gt;

&lt;p&gt;Once both sides are normalized, use getTime() to compare — it returns an integer (milliseconds since epoch) and === works correctly. Direct Date object comparison with == does not, because two different Date objects are different references even when they represent the same moment.&lt;/p&gt;

&lt;h2&gt;
  
  
  Sheet layout and reading the data
&lt;/h2&gt;

&lt;p&gt;The script assumes a sheet named 'Tasks' with three columns: column A is the recipient email, column B is the due date (formatted as a date in Sheets, not plain text), and column C is a task name used in the email subject and body.&lt;/p&gt;

&lt;p&gt;getDataRange().getValues() returns a two-dimensional array. Row 0 is your header row, so the loop starts at i = 1. Each data[i] is one row; data[i][0] is the email, data[i][1] is the date object, data[i][2] is the task name. If your sheet has columns in a different order, adjust the index numbers accordingly.&lt;/p&gt;

&lt;p&gt;One practical note: if column B contains a plain text string ('June 10') instead of a real Sheets date, wrapping it in new Date() will still parse it, but you lose timezone-safe behavior and cross-locale reliability. Format the column as Date in Sheets and let the serialization handle it cleanly.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up the daily trigger
&lt;/h2&gt;

&lt;p&gt;The script does nothing on its own. You need a time-driven trigger to run sendDateReminders() once per day. In the Apps Script editor, go to Triggers (the clock icon), add a new trigger, set the function to sendDateReminders, choose Time-driven, Day timer, and pick a time window like 7am–8am.&lt;/p&gt;

&lt;p&gt;Pick a time window that's early enough to be useful but after the spreadsheet's timezone midnight. If your sheet is set to US/Eastern and you run the trigger at 1am UTC (which is 8pm Eastern the previous day), today inside the script will be one calendar day behind what your users expect. Match the trigger's timezone — set under Project Settings — to the sheet's timezone.&lt;/p&gt;

&lt;p&gt;MailApp.sendEmail() counts against your daily Gmail quota: 100 emails per day for personal accounts, 1,500 for Google Workspace. If you have more than a few hundred rows, batch sends or switch to GmailApp.sendEmail() with an alias. For most reminder sheets, MailApp is fine.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why does my reminder send a day early?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The date cell arrives as a Date object with a non-zero time component. When you compare it to today without zeroing both sides with setHours(0,0,0,0), a cell storing midnight of the target date is numerically less than today's timestamp mid-morning — so the comparison passes a day before it should.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I send reminders X days before the due date instead of on the day itself?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. After normalizing today with setHours(0,0,0,0), add the offset: var target = new Date(today.getTime()); target.setDate(target.getDate() + 3); then compare dueDate.getTime() === target.getTime(). Adjust the number to however many days ahead you want the reminder.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;My script runs but no emails arrive — what should I check first?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Run sendDateReminders() manually from the editor and watch the execution log. The most common causes are: (1) the sheet name in getSheetByName() doesn't match exactly (case-sensitive), (2) column B contains text strings instead of real date values, or (3) the script hasn't been granted Gmail permission yet — the first manual run prompts for OAuth authorization that a trigger won't prompt for on its own.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Does this work if recipients are different people per row?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. MailApp.sendEmail() accepts any valid address in column A — it sends a separate email per matching row. Each call is independent, so different rows can have different recipients, subjects, and body text.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Want the plain-English version? Describe the automation at &lt;a href="https://bulldo.gs/send-email-reminders-based-on-dates-in-google-sheets" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; and get working Apps Script back — free, no login.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googleappsscript</category>
      <category>javascript</category>
      <category>googlesheets</category>
    </item>
    <item>
      <title>Save Gmail attachments to a Drive folder in Gmail</title>
      <dc:creator>bulldo.gs</dc:creator>
      <pubDate>Sat, 13 Jun 2026 00:29:43 +0000</pubDate>
      <link>https://dev.to/bulldo_gs/save-gmail-attachments-to-a-drive-folder-in-gmail-5c4e</link>
      <guid>https://dev.to/bulldo_gs/save-gmail-attachments-to-a-drive-folder-in-gmail-5c4e</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally written for &lt;a href="https://bulldo.gs/save-gmail-attachments-to-drive-in-gmail" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; — republished here with the canonical link pointing home.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I need to automatically pull file attachments out of Gmail and store them in a Drive folder without manually saving each one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// saveAttachments.gs — run on a time trigger or manually&lt;/span&gt;
&lt;span class="c1"&gt;// Saves qualifying Gmail attachments to a named Drive folder.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;saveAttachmentsToDrive&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;folderName&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Gmail Attachments&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;searchQuery&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;has:attachment newer_than:1d&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;folders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;DriveApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getFoldersByName&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;folderName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;folder&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;folders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;hasNext&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;folders&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DriveApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createFolder&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;folderName&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;threads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;GmailApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;searchQuery&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;threads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;threads&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;getMessages&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
    &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;attachments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;messages&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;j&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;getAttachments&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
      &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;attachments&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;att&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;attachments&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;k&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
        &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;ct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;att&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getContentType&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ct&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;application/ics&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;ct&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;indexOf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;image/&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nx"&gt;att&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getSize&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;20000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
        &lt;span class="nx"&gt;folder&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;createFile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;att&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What getAttachments() actually returns
&lt;/h2&gt;

&lt;p&gt;The Gmail API's getAttachments() method gives you everything attached to the MIME envelope: PDFs, spreadsheets, Word docs, but also every inline image in every HTML signature, and the .ics calendar invite that Google Calendar automatically appends to meeting emails. The first time I ran a naive version of this script against a busy inbox I ended up with 400 tiny PNG files of company logos, an .ics for every calendar event in the last week, and exactly zero of the invoices I was trying to collect.&lt;/p&gt;

&lt;p&gt;The fix is to filter on content type before writing to Drive. The script above skips application/ics unconditionally. For images it applies a size heuristic: anything under 20,000 bytes is almost certainly a signature icon or a spacer pixel, not a real attachment. That threshold holds well in practice; a genuine photograph or scanned document is rarely under 20 KB. Adjust the number if your domain sends small image attachments legitimately.&lt;/p&gt;

&lt;p&gt;Note that getAttachments() does not expose inline images that are referenced via a Content-ID header but hosted remotely (tracked pixel style). Those never appear in the list, so you don't need to filter them. The ones you do need to filter are locally-attached inline parts, which Gmail encodes as standard MIME attachments with an image/* content type.&lt;/p&gt;

&lt;h2&gt;
  
  
  Targeting the right messages with search operators
&lt;/h2&gt;

&lt;p&gt;GmailApp.search() accepts the same operators as the Gmail search box: has:attachment newer_than:1d, label:invoices has:attachment, from:&lt;a href="mailto:vendor@example.com"&gt;vendor@example.com&lt;/a&gt; has:attachment. The 100-thread limit per search call is the main operational constraint — if you're processing more than 100 threads per day, you need to paginate using the two-argument form GmailApp.search(query, start, max) and loop until the result set is empty.&lt;/p&gt;

&lt;p&gt;Using newer_than:1d on a daily time trigger means you'll reprocess the same messages that arrived in the last 24 hours on every run, which creates duplicate files in Drive. The cleaner pattern is to apply a Gmail label after processing a thread (thread.addLabel()) and exclude that label from the search query with -label:saved-to-drive. That way each message is processed exactly once regardless of trigger timing.&lt;/p&gt;

&lt;p&gt;The search operator is:unread is tempting as a proxy for new messages, but it breaks the moment someone reads a thread before the trigger fires. Label-based deduplication is more reliable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Setting up a time trigger without touching the UI
&lt;/h2&gt;

&lt;p&gt;You can register the trigger programmatically so anyone who copies the script doesn't have to click through the Apps Script editor triggers panel:&lt;/p&gt;

&lt;p&gt;Add a one-time setup function: function createTrigger() { ScriptApp.newTrigger('saveAttachmentsToDrive').timeBased().everyDays(1).atHour(7).create(); }. Run it once, then delete it or leave it — calling it again creates a duplicate trigger, which doubles your Drive writes.&lt;/p&gt;

&lt;p&gt;The script requires two OAuth scopes: &lt;a href="https://www.googleapis.com/auth/gmail.readonly" rel="noopener noreferrer"&gt;https://www.googleapis.com/auth/gmail.readonly&lt;/a&gt; and &lt;a href="https://www.googleapis.com/auth/drive" rel="noopener noreferrer"&gt;https://www.googleapis.com/auth/drive&lt;/a&gt;. Apps Script infers these from the service calls you make, so you don't declare them manually. On first run the authorization dialog will ask you to approve both. If you later add GmailApp.search() with a modify operator (like addLabel), the scope widens to gmail.modify and the authorization prompt fires again for any existing users of the script.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Why are duplicate files appearing in my Drive folder?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The script re-runs against the same messages each time unless you mark processed threads. Add a Gmail label after saving (thread.addLabel(GmailApp.getUserLabelByName('saved-to-drive'))) and include -label:saved-to-drive in your search query. Create the label in Gmail first or use GmailApp.createLabel() once in a setup function.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I save attachments to a subfolder organized by sender or date?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Replace the single folder.createFile(att) call with logic that creates or fetches a subfolder first: var sub = getOrCreateSubfolder(folder, messages[j].getFrom()). DriveApp.getFoldersByName() searches across all of Drive, not just children of a specific folder, so use folder.getFoldersByName() on the parent folder object to scope the lookup correctly.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The script times out before finishing. How do I handle a large backlog?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Apps Script has a 6-minute execution limit. For large backlogs, process one thread per run and store the last-processed thread index in PropertiesService (PropertiesService.getScriptProperties().setProperty('lastIndex', i)). Read it back at the start of each run and resume from there. Combine this with a short trigger interval (every 5 minutes) to drain the backlog incrementally.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I skip Google Workspace native files like Docs or Sheets that show up as attachments?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Call att.isGoogleType() before saving. It returns true for Docs, Sheets, Slides, and Forms attached as Drive links. Those can't be written to Drive via createFile() anyway — the call throws — so checking isGoogleType() and skipping is both the correct filter and the error prevention.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Want the plain-English version? Describe the automation at &lt;a href="https://bulldo.gs/save-gmail-attachments-to-drive-in-gmail" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; and get working Apps Script back — free, no login.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googleappsscript</category>
      <category>javascript</category>
      <category>gmail</category>
      <category>googledrive</category>
    </item>
    <item>
      <title>Send personalized emails from a sheet in Gmail</title>
      <dc:creator>bulldo.gs</dc:creator>
      <pubDate>Sat, 13 Jun 2026 00:22:40 +0000</pubDate>
      <link>https://dev.to/bulldo_gs/send-personalized-emails-from-a-sheet-in-gmail-44j3</link>
      <guid>https://dev.to/bulldo_gs/send-personalized-emails-from-a-sheet-in-gmail-44j3</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally written for &lt;a href="https://bulldo.gs/send-personalized-emails-from-a-sheet-in-gmail" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; — republished here with the canonical link pointing home.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I have a spreadsheet of names and email addresses and I want to send each person a personalized message from my Gmail account without copy-pasting or using a paid tool.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// Mail merge: Sheet cols A=Name, B=Email, C=Sent&lt;/span&gt;
&lt;span class="c1"&gt;// Run from Apps Script; authorize Gmail + Sheets scopes&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;sendMerge&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SpreadsheetApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getActiveSheet&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDataRange&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getValues&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;quota&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;MailApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRemainingDailyQuota&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;sent&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;continue&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;sent&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="nx"&gt;quota&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;Logger&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Quota reached at row &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
      &lt;span class="k"&gt;break&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;email&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hey &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;, here is your update&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Hi &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="nx"&gt;name&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s1"&gt;Your personalized content goes here.&lt;/span&gt;&lt;span class="se"&gt;\n\n&lt;/span&gt;&lt;span class="s1"&gt;Thanks&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
    &lt;span class="nx"&gt;MailApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sendEmail&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;subject&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setValue&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Sent&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="nx"&gt;sent&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Set up your sheet and open the script editor
&lt;/h2&gt;

&lt;p&gt;Put names in column A, email addresses in column B, and leave column C blank — the script writes 'Sent' there as it goes. Header row in row 1 is assumed; the loop starts at index 1 (row 2) to skip it. Open the script editor from Extensions &amp;gt; Apps Script, paste the function, and save.&lt;/p&gt;

&lt;p&gt;The first time you run sendMerge() Google will ask you to authorize two scopes: Sheets (read/write the active spreadsheet) and Gmail (send mail on your behalf). Both are required. If you only see a Sheets prompt, delete the file and re-paste — a cached partial authorization sometimes skips the Gmail scope on older script files.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why the Sent column is the whole point
&lt;/h2&gt;

&lt;p&gt;Consumer Google accounts cap at roughly 100 outgoing recipients per 24-hour rolling window via MailApp. If your list has 200 rows and you run the script at 11 pm, it will send 100 and log 'Quota reached at row 101'. Without the Sent check, a second run the next morning resends rows 1-100 to people who already heard from you.&lt;/p&gt;

&lt;p&gt;The guard is two lines: skip rows already marked Sent, and check getRemainingDailyQuota() before each send rather than once at the top. Quota resets at midnight Pacific, not at a fixed offset from your first send, so a pre-loop check can be stale by the time you reach row 80. Checking inside the loop costs nothing and catches the edge.&lt;/p&gt;

&lt;p&gt;I keep a copy of this pattern in a utils file I paste into every client sheet project — the first time I forgot the Sent column I sent a 'welcome' message to a 40-person list twice in one afternoon. Nobody complained loudly, but it was avoidable.&lt;/p&gt;

&lt;h2&gt;
  
  
  Personalize beyond name substitution
&lt;/h2&gt;

&lt;p&gt;The body string uses plain concatenation rather than a template, which makes the highlighting cleaner but also makes it easy to pull additional columns. Add a column D for a custom note, read it as rows[i][3], and concatenate it into the body the same way. You can also pass an options object as a fourth argument to MailApp.sendEmail() to set a replyTo address, cc, or an HTML body — the signature is sendEmail(recipient, subject, body, options).&lt;/p&gt;

&lt;p&gt;For HTML email, set options.htmlBody to a string of markup. The plain body argument still needs to be present as a fallback for mail clients that strip HTML. Keep both in sync or you will confuse recipients whose clients show the plain version.&lt;/p&gt;

&lt;p&gt;Workspace (paid Google) accounts get a higher quota — 1,500 recipients per day — and can send as an alias using GmailApp.sendEmail() with a 'from' field. MailApp does not support alias sending; if that matters, swap the send call to GmailApp.sendEmail() with the same argument order.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;What is the daily email limit for Apps Script MailApp?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Consumer (free) accounts: approximately 100 recipients per 24-hour window. Google Workspace accounts: 1,500 per day. getRemainingDailyQuota() returns the live number so you can check mid-loop rather than guessing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I send HTML email with this script?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Yes. Pass a fourth argument to MailApp.sendEmail(): an options object with an htmlBody property set to your HTML string. Keep the plain-text body argument in place as a fallback — it is required even when htmlBody is set.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The script sent duplicates. What happened?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Most likely the Sent column check was missing or the column index was wrong (columns are zero-indexed in getValues(), so column C is index 2, not 3). Confirm sheet.getRange(i + 1, 3).setValue('Sent') writes to the correct column before your next run.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I run this on a schedule instead of manually?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;In the Apps Script editor, open Triggers (the clock icon), add a time-driven trigger pointing to sendMerge, and set your interval. The Sent column makes repeated scheduled runs safe — already-sent rows are skipped automatically.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Want the plain-English version? Describe the automation at &lt;a href="https://bulldo.gs/send-personalized-emails-from-a-sheet-in-gmail" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; and get working Apps Script back — free, no login.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googleappsscript</category>
      <category>javascript</category>
      <category>gmail</category>
    </item>
    <item>
      <title>Remove duplicate rows in Google Sheets</title>
      <dc:creator>bulldo.gs</dc:creator>
      <pubDate>Sat, 13 Jun 2026 00:22:22 +0000</pubDate>
      <link>https://dev.to/bulldo_gs/remove-duplicate-rows-in-google-sheets-42jb</link>
      <guid>https://dev.to/bulldo_gs/remove-duplicate-rows-in-google-sheets-42jb</guid>
      <description>&lt;blockquote&gt;
&lt;p&gt;Originally written for &lt;a href="https://bulldo.gs/remove-duplicate-rows-in-google-sheets" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; — republished here with the canonical link pointing home.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I have a Google Sheet with duplicate rows and I want to remove them programmatically, either on demand or on a schedule, without destroying the rest of my data.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// removeDuplicates.gs — dedup active sheet, keep first occurrence&lt;/span&gt;
&lt;span class="c1"&gt;// Run from Extensions &amp;gt; Apps Script, or bind to a trigger.&lt;/span&gt;
&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;removeDuplicateRows&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;SpreadsheetApp&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getActiveSheet&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getDataRange&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;getValues&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;seen&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Set&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;unique&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[];&lt;/span&gt;
  &lt;span class="k"&gt;for &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="nx"&gt;key&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;|&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;has&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="nx"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;key&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
      &lt;span class="nx"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;push&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;i&lt;/span&gt;&lt;span class="p"&gt;]);&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;clearContents&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="nx"&gt;sheet&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;getRange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;setValues&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;unique&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why rewrite instead of delete
&lt;/h2&gt;

&lt;p&gt;The instinct when deduplicating is to loop through the sheet and call deleteRow on each duplicate. That works, but it has a sharp edge: every call to deleteRow shifts all rows below it up by one. If you delete row 3, what was row 4 is now row 3, and your loop index is already pointing at the new row 4. The safe workaround people reach for is iterating bottom-to-top, which works but means holding the full duplicate set in memory anyway, making one API call per deleted row.&lt;/p&gt;

&lt;p&gt;The approach here sidesteps the problem entirely. Read everything once with getDataRange().getValues() — a single API call that returns a 2D array. Build the deduplicated array in JavaScript using a Set to track which row fingerprints you have already seen. Then clear the sheet and write the result back with one setValues call. Two API calls total, regardless of how many duplicates you had. For a 10,000-row sheet, this is the difference between a script that finishes in two seconds and one that times out at the six-minute Apps Script execution limit.&lt;/p&gt;

&lt;p&gt;The row key is built with data[i].join('|'). The pipe character works as a separator in practice; if your data actually contains pipe characters in every column, use a multi-character sentinel like '||~||' to eliminate false collisions. I have watched this bite people on CRM exports where one column was a phone number formatted as +1|555|0100 — the joined key collides across rows that are genuinely distinct.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it to a trigger or a menu item
&lt;/h2&gt;

&lt;p&gt;Running removeDuplicateRows manually from the Apps Script editor is fine for one-off cleanup. For anything recurring — a sheet fed by a form, a nightly import, a webhook dump — you want a trigger.&lt;/p&gt;

&lt;p&gt;In the Apps Script editor, open the clock icon (Triggers), add a new trigger, point it at removeDuplicateRows, and set it to time-driven on whatever cadence matches your ingest rate. The script runs as you, under your quota, so it counts against your daily Apps Script runtime allowance (6 minutes per execution, 90 minutes per day on the free tier).&lt;/p&gt;

&lt;p&gt;If you want a button in the sheet UI instead, add this alongside the existing function: function onOpen() { SpreadsheetApp.getActiveSpreadsheet().addMenu('Tools', [{name: 'Remove duplicates', functionName: 'removeDuplicateRows'}]); }. The onOpen trigger fires automatically each time the spreadsheet opens and puts a menu entry under a custom Tools header. The first time I set this up for a client, I forgot that onOpen only runs for the user who opens the file — shared sheets where a non-owner triggers it still work, because the menu calls the bound script, which runs as the script owner.&lt;/p&gt;

&lt;h2&gt;
  
  
  Deduping on a subset of columns, not the whole row
&lt;/h2&gt;

&lt;p&gt;Sometimes two rows are duplicates for your purposes even if one column differs — a timestamp column, an auto-incremented ID, a status field that got updated. In that case, building the key from the entire row gives you false negatives (rows that should be collapsed but aren't, because column G differs by a second).&lt;/p&gt;

&lt;p&gt;To key on specific columns only, replace the join line. If columns A, B, and C (indexes 0, 1, 2) define uniqueness: var key = [data[i][0], data[i][1], data[i][2]].join('|'). Everything else in the script stays the same. The unique array still keeps the full row — you are only changing what counts as a duplicate, not what gets written back.&lt;/p&gt;

&lt;p&gt;One practical note: this keeps the first occurrence. If the column you excluded (say, a timestamp) is meaningful and you want the most recent occurrence rather than the earliest, sort the data descending by that column before the dedup loop. getValues returns rows in sheet order, so pre-sort in the sheet or add a JavaScript sort step before the Set iteration.&lt;/p&gt;

&lt;h2&gt;
  
  
  FAQ
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Will this delete my header row?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;No, as long as your header is row 1 and is unique (which it almost always is — column names do not repeat). The Set sees it first and keeps it. If you have a pathological case where two header rows are identical, add an if (i === 0) { unique.push(data[i]); continue; } guard at the top of the loop.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The script runs but then the sheet is blank — what happened?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Almost certainly clearContents fired and then setValues failed silently because unique was empty or unique[0] was undefined. This happens when getDataRange returns a sheet with no data (e.g., a freshly created tab) or when all rows were considered duplicates of each other — check that your separator character does not appear in every cell of a column. Add a guard: if (unique.length === 0) return; before the clearContents call.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Can I run this on a specific named sheet instead of the active one?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Replace SpreadsheetApp.getActiveSheet() with SpreadsheetApp.getActiveSpreadsheet().getSheetByName('Your Sheet Name'). getSheetByName returns null if the name does not match exactly (case-sensitive, leading/trailing spaces count), so add a null check if you are running this from a trigger where the active sheet might not be the one you intend.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do I dedup across multiple sheets at once?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Wrap the body of removeDuplicateRows in a helper that accepts a sheet argument, then iterate: var sheets = SpreadsheetApp.getActiveSpreadsheet().getSheets(); for (var s = 0; s &amp;lt; sheets.length; s++) { dedupSheet(sheets[s]); }. Each sheet gets its own isolated Set, so duplicates are evaluated per-sheet, not across sheets.&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Want the plain-English version? Describe the automation at &lt;a href="https://bulldo.gs/remove-duplicate-rows-in-google-sheets" rel="noopener noreferrer"&gt;bulldo.gs&lt;/a&gt; and get working Apps Script back — free, no login.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>googleappsscript</category>
      <category>javascript</category>
      <category>googlesheets</category>
    </item>
  </channel>
</rss>
