You can’t delete your primary Google Calendar. Google doesn’t let you, it’s tied to your account. So when someone wants a totally clean slate, the only official option is the “Remove all events” flow buried in Calendar settings.

I tried that recently for someone with years of accumulated events on their primary calendar. Click it, and a little “Deleting…” toast pops up from the bottom of the page like every other Google Calendar action. Except this one just sits there. Eventually it either says failed, or it dismisses itself quietly like the job finished. Refresh the page either way and the calendar’s still full. Ran it again. Same result. No useful error, no explanation, just a toast lying to you and a pile of events that refused to leave.

My guess is it’s a timeout problem. If you’ve got years of recurring meetings, reminders, and one-off events, Google’s bulk delete is trying to process all of it in one request, and it just falls over before it finishes. There’s no progress bar telling you how far it got, so you can’t even tell if it’s close.

This isn’t user error. It looks like a genuine bug sitting in Google’s own bulk delete. So here’s the workaround.

The fix: let Apps Script do it in small, boring batches

The web UI wants to do this in one shot. The trick is to not do that. Instead, delete a small batch of events, wait a bit, delete the next batch, repeat, for as long as it takes. That’s it. Tedious, but reliable, because each individual request is small enough to actually complete.

Google Apps Script is the easiest way to do this without installing anything. It’s a free scripting environment that comes with every Google account, personal Gmail included, not just Workspace, most people have just never heard of it because Google doesn’t put it anywhere near the normal UI. You write plain JavaScript, it runs on Google’s servers under your own account, and it can call things like the Calendar API directly. No local setup, no auth tokens to manage, it’s already logged in as you.

1. Set up the script

Go to script.google.com and create a new project. Paste this in:

function startTrigger() {
  // Clear any existing triggers to avoid duplicates
  stopTrigger();

  // Run the batch delete function every 1 minute
  ScriptApp.newTrigger('autoBatchDeleteEvents')
    .timeBased()
    .everyMinutes(1)
    .create();

  Logger.log('Trigger started! The script will now run in the background every minute.');
}

function stopTrigger() {
  var triggers = ScriptApp.getProjectTriggers();
  for (var i = 0; i < triggers.length; i++) {
    if (triggers[i].getHandlerFunction() === 'autoBatchDeleteEvents') {
      ScriptApp.deleteTrigger(triggers[i]);
    }
  }
  Logger.log('Trigger stopped.');
}

function autoBatchDeleteEvents() {
  var calendarId = 'primary';
  var deletedCount = 0;
  var maxDeletions = 150;

  var response = Calendar.Events.list(calendarId, {
    maxResults: maxDeletions,
    showDeleted: false,
    orderBy: 'startTime', // Sorts old to new
    singleEvents: true    // Expands recurring events chronologically
  });

  var events = response.items;

  // When no events are left, stop the script from running again
  if (!events || events.length === 0) {
    Logger.log('No active events found. Calendar is empty! Stopping trigger...');
    stopTrigger();
    return;
  }

  // Log the time range of this current batch
  var firstEventDate = events[0].start.dateTime || events[0].start.date;
  var lastValidEvent = events[events.length - 1];
  var lastEventDate = lastValidEvent.start.dateTime || lastValidEvent.start.date;

  Logger.log('--- Current Progress: Deleting events from ' + firstEventDate + ' to ' + lastEventDate + ' ---');

  for (var i = 0; i < events.length; i++) {
    if (events[i].status === 'cancelled') {
      continue;
    }

    try {
      Calendar.Events.remove(calendarId, events[i].id);
      deletedCount++;
    } catch (e) {
      Logger.log('Could not delete event: ' + e.message);
    }

    // Pause after every API call so we don't trip rate limits
    Utilities.sleep(200);
  }

  Logger.log('Successfully deleted ' + deletedCount + ' events this minute.');
}

2. Turn on the Calendar API service

This part isn’t optional and it’s the one thing that’ll trip you up if you skip it. Calendar.Events isn’t available by default, it’s an “Advanced Google Service” you have to enable per-project.

In the Apps Script editor, click Services (the plus icon in the left sidebar), find Google Calendar API, and add it. If you skip this step the script fails immediately with Calendar is not defined.

3. Run it, then wait

Select autoBatchDeleteEvents from the function dropdown and run it first, not startTrigger. This is the function that actually touches Calendar data, so it’s the one that fires the authorization prompt, approve it. startTrigger on its own just calls ScriptApp.newTrigger, and I’m not convinced it forces the same authorization on its own, so I ran the real delete function manually once first to be sure the permission was granted before letting it run unattended.

Once that first batch has run and you’ve approved access, select startTrigger and run that. From here it runs itself, once a minute, chewing through 150 events at a time with a 200ms pause between deletes to stay clear of Google’s rate limits. You can watch it work in the Apps Script execution log.

For a calendar with years of history, this took a little over two hours for me. Not fast. But it didn’t fail once, which is more than I can say for the web UI’s version of this.

4. Clean up when it’s done

The script stops the trigger itself once Calendar.Events.list comes back empty, so you don’t have to babysit it. Once it logs “Calendar is empty,” go back into the Apps Script project and delete it entirely (or at least double check the trigger is gone under Triggers in the sidebar). No reason to leave an unused script sitting around with calendar delete permissions.

Why the batching actually works

The singleEvents: true option is what makes this safe against recurring events. Without it, a recurring series comes back as a single object and deleting it nukes the whole series in one shot, which usually isn’t what you want if you’re trying to be careful. With it, the API expands every occurrence individually, and orderBy: 'startTime' processes them oldest first so you can watch progress in the logs and see it’s actually moving.

The maxDeletions = 150 and the 200ms sleep are both just there to stay under Google’s per-minute request quotas. Push it much higher and you’ll start seeing 429 rate limit errors in the log instead of clean deletions. 150 with a short pause was reliable in practice; I didn’t bother tuning it further since it worked.