Sync Google Calendars for Free


Updated: 1 March 2025
Update: I was hitting API rate limits so I made a few tweaks including adding a "backoff" function and not synching events where I haven't accepted all-day holidays which end with "day". Latest code is below.

My calendar chaos!

I often work for multiple clients at once on the basis of outcomes delivered. Recently I was helping a government agency to adopt OKRs. It was a high-touch engagement involving the design and facilitation of dozens of workshops for hundreds of people across 30+ teams, writing internal guidance, building and deploying some custom tooling, and nurturing an internal group of OKR Ambassadors over more than half a year. To simplify scheduling and collaboration the client gave me an account on their Google Workspace.

Sounds great, right? Using their Google Workspace account ensured data protection and compliance and made me more effective. I couldn’t have taken on such a large and complex engagement without it but managing two separate Google Calendars quickly became a logistical nightmare. Between juggling my personal life and time with other clients, I’d occasionally double-book myself—or worse, miss appointments entirely.

I quickly realised I needed a better way to sync both calendars. I looked briefly at some commercial solutions and then I remembered the power & simplicity of Google Apps Script (GAS).

I wondered if I could build something simple myself or with help from generative AI!

Getting help from GenAI

I opened ChatGPT and described what I wanted via this prompt:

Please write a google workspace script which will "sync" two google calendars I own by creating an event called "HOLD" in each calendar where an event exists in the other calendar. When events are removed from the source calendar, the "hold" events should be removed from the other calendar. The script should run every 20 minutes during business hours.

Immediately, I had a mostly working script!

I kept chatting to do a bit more refinement; I wanted to disable superfluous reminders on the HOLD events and access the free/busy flag on events directly via the Calendar Event API. It turns out there’s no way to access this directly but I got the script working well enough within 15 minutes. It syncs in both directions by loading events from both calendars by adding a “HOLD” placeholder in one calendar for accepted events in the other.

It does this automatically every hour, automatically cleaning up the placeholders if the original event gets canceled, deleted, or declined and prevents unnecessary reminders from being created on “HOLD” events.

I was really impressed with the cleanliness of the code, the reusable functions, and the code for creating and removing the trigger.

You can read my entire conversation with ChatGPT.

And the cost? Nothing. Zero. Only the time it took me to chat with the AI and set it up.

It’s been happily keeping both calendars in harmony for 6 months with zero issues and zero intervention on my part.

Now I get HOLD events in each calendar when I'm committed in the other!
Now I get HOLD events in each calendar when I'm committed in the other!

How you can use it

Are you also juggling multiple Google calendars across workspaces that need to be kept in sync? Give the script below a whirl and let me know how you get on!

  1. Open Google Apps Script and create a new project.
  2. Copy and paste the script (below) into the editor.
  3. Replace the placeholder calendar IDs with your own.
  4. Run it once (you’ll need to authorize it) to test it.
  5. Call the createTrigger() function to set up automatic syncing.

Happy AI Coding!

This script has taken a huge administrative weight off my shoulders and the experience of being able to build simple apps like this is a game changer and adds a tremendous value to my already favourite productivity suite. I can’t wait to build more.

I hope it helps you, too. I’d love to hear from you how you’re using it as well as other experiences you’ve had with GAS and ChatGPT to build simple utilities like this.

  1// Sync two calendars by creating a 'HOLD' event in the corresponding calendar (or deleting as appropriate)
  2// Runs every X minutes between A-B hours (see createTrigger function)
  3
  4var DEBUG_MODE = false; // Set to true for debug mode (only logs events without modifying them)
  5
  6function syncCalendars() {
  7  // Replace with your Calendar IDs
  8  var calendarId1 = 'XXX@XXXXXX.com';
  9  var calendarId2 = 'XXX.XXX@XXX.XXXX.com';
 10
 11  var holdEventName = 'xHOLDx'; // Change this to whatever name you prefer
 12  var daysAheadToSync = 20;
 13
 14  var calendar1 = CalendarApp.getCalendarById(calendarId1);
 15  var calendar2 = CalendarApp.getCalendarById(calendarId2);
 16
 17  var now = new Date();
 18  var startTime = new Date(now.getFullYear(), now.getMonth(), now.getDate());
 19  var endTime = new Date(startTime);
 20  endTime.setDate(endTime.getDate() + daysAheadToSync);
 21
 22  // Load cached processed events
 23  var properties = PropertiesService.getScriptProperties();
 24  var processedEvents = JSON.parse(properties.getProperty('processedEvents') || '{}');
 25
 26  syncFromSourceToTarget(calendar1, calendar2, startTime, endTime, holdEventName, processedEvents);
 27  syncFromSourceToTarget(calendar2, calendar1, startTime, endTime, holdEventName, processedEvents);
 28
 29  // Save updated processed events
 30  properties.setProperty('processedEvents', JSON.stringify(processedEvents));
 31}
 32
 33function syncFromSourceToTarget(sourceCalendar, targetCalendar, startTime, endTime, holdEventName, processedEvents) {
 34  var sourceEvents = sourceCalendar.getEvents(startTime, endTime);
 35  var targetEvents = targetCalendar.getEvents(startTime, endTime, { search: holdEventName });
 36
 37  var deletionDelayHours = 2; // Delay deletion by 2 hours
 38
 39  // Remove HOLD events in target calendar if the source event no longer exists
 40  targetEvents.forEach(function (targetEvent) {
 41    var relatedEvent = sourceEvents.find(function (event) {
 42      return event.getStartTime().getTime() === targetEvent.getStartTime().getTime() &&
 43        event.getEndTime().getTime() === targetEvent.getEndTime().getTime();
 44    });
 45
 46    var now = new Date();
 47    var eventStart = targetEvent.getStartTime();
 48    if (!relatedEvent && (eventStart - now) / 3600000 > deletionDelayHours) {
 49      Logger.log('[DEBUG] Would delete: ' + targetEvent.getTitle() + ' starting at ' + targetEvent.getStartTime());
 50      if (!DEBUG_MODE) {
 51        retryWithBackoff(function () {
 52          targetEvent.deleteEvent();
 53        }, 3);
 54      }
 55    }
 56  });
 57
 58  // Add HOLD events in target calendar for accepted events in source calendar
 59  sourceEvents.forEach(function (sourceEvent) {
 60    var eventTitle = sourceEvent.getTitle();
 61
 62    // Ignore HOLD events
 63    if (eventTitle !== holdEventName) {
 64      var isAllDay = sourceEvent.isAllDayEvent();
 65      var containsDay = /day/i.test(eventTitle);
 66
 67      
 68      // Skip only all-day events that end in "day"
 69      if (!(isAllDay && containsDay)) {
 70        var myStatus = sourceEvent.getMyStatus(); // Get user's response
 71        if (myStatus === CalendarApp.GuestStatus.YES || myStatus === CalendarApp.GuestStatus.OWNER ) { // Only sync if the event is accepted
 72          var relatedEvent = targetEvents.find(function (event) {
 73            return event.getStartTime().getTime() === sourceEvent.getStartTime().getTime() &&
 74                   event.getEndTime().getTime() === sourceEvent.getEndTime().getTime();
 75          });
 76
 77          if (!relatedEvent && !processedEvents[sourceEvent.getId()]) {
 78            if (!DEBUG_MODE) {
 79              retryWithBackoff(function () {
 80                var newEvent = targetCalendar.createEvent(holdEventName, sourceEvent.getStartTime(), sourceEvent.getEndTime());
 81                newEvent.removeAllReminders(); // Remove reminders only at creation
 82              }, 3);
 83
 84              processedEvents[sourceEvent.getId()] = true; // Mark event as processed
 85            }else {
 86              Logger.log('[DEBUG] Would create: ' + eventTitle + ' starting at ' + sourceEvent.getStartTime());
 87            }
 88          }else {
 89            if (DEBUG_MODE) {
 90              Logger.log('[DEBUG] Not syncing ' + eventTitle + 'related title: ' + relatedEvent.getTitle());
 91            }
 92          }
 93        }
 94      } else {
 95        if (DEBUG_MODE) {
 96          Logger.log('[DEBUG] Skipping all-day event ending with "day": ' + eventTitle);
 97        }
 98      }
 99    }
100  });
101}
102
103function createTrigger() {
104  ScriptApp.newTrigger('syncCalendars')
105    .timeBased()
106    .everyHours(2) // Runs every 2 hours instead of every 20 minutes
107    .create();
108}
109
110function deleteTriggers() {
111  var triggers = ScriptApp.getProjectTriggers();
112  triggers.forEach(function (trigger) {
113    ScriptApp.deleteTrigger(trigger);
114  });
115}
116
117// Helper function to handle retries with exponential backoff
118function retryWithBackoff(fn, retries) {
119  for (var i = 0; i < retries; i++) {
120    try {
121      return fn();
122    } catch (e) {
123      Logger.log('Error: ' + e.message + ' (Retrying in ' + (i + 1) + 's)');
124      Utilities.sleep((i + 1) * 1000); // Wait before retrying
125    }
126  }
127}