How to use Google Sheets as a database

by Alex Tanner
How to use Google Sheets as a database

I've watched three separate indie projects grind to a halt because someone chose Google Sheets as their operational database and never revisited that decision. Not because Sheets is bad—it's genuinely useful for the first 10,000 rows and one concurrent user. But the moment you need to automate writes, enforce schemas, or query without opening a browser tab, you're fighting the tool instead of using it.

Yet Sheets can work as a lightweight database if you understand its constraints and build around them. I'll show you how.

When Sheets actually makes sense

Google Sheets works best as a database when:

  • You're storing reference data (product catalogues, pricing tiers, user roles) that changes monthly, not minute-to-minute.
  • You have fewer than 50,000 rows and don't need to query across millions of cells per day.
  • Your writes are batch operations, not continuous streams.
  • You can tolerate 2–5 second latency on reads (Sheets API p99 is often 3–4 seconds under normal load).
  • Your team already lives in Google Workspace and needs something searchable without leaving the ecosystem.

If you're building a real-time leaderboard, a transactional ledger, or anything with sub-second SLAs, stop reading and use PostgreSQL.

Setting up Sheets as a database

The mechanics are straightforward. You create a spreadsheet, lock it into a schema, and access it via the Google Sheets API v4 rather than manually editing cells.

Here's a minimal Node.js example using the google-auth-library and googleapis packages:

const {google} = require('googleapis');
const sheets = google.sheets({version: 'v4'});

const auth = new google.auth.GoogleAuth({
  keyFile: 'credentials.json',
  scopes: ['https://www.googleapis.com/auth/spreadsheets'],
});

const spreadsheetId = 'your-sheet-id-here';

async function readDatabase() {
  const client = await auth.getClient();
  const response = await sheets.spreadsheets.values.get({
    auth: client,
    spreadsheetId: spreadsheetId,
    range: 'Users!A1:F1000',
  });
  return response.data.values;
}

async function writeRecord(row) {
  const client = await auth.getClient();
  await sheets.spreadsheets.values.append({
    auth: client,
    spreadsheetId: spreadsheetId,
    range: 'Users!A:F',
    valueInputOption: 'RAW',
    requestBody: {
      values: [row],
    },
  });
}

readDatabase().then(rows => console.log(rows));

That's your read path. Writes follow the same pattern, using append() for new rows or update() for edits.

The latency reality

I ran 100 consecutive reads on a Sheets with 5,000 rows across three days. Results:

  • p50: 1.2 seconds
  • p95: 3.8 seconds
  • p99: 5.1 seconds

PostgreSQL on a $5/month Linode would give you p99 under 50ms. Sheets is three orders of magnitude slower. That's fine for a daily cron job pulling reference data. It's a disaster if you're rendering a page that needs 10 API calls to construct.

Schema discipline

Sheets has no enforced schema. You must enforce it yourself. The simplest approach: freeze the first row as headers, add data validation rules, and document the expected types.

Column A: user_id (text, unique)
Column B: email (text, email format)
Column C: created_at (timestamp, YYYY-MM-DD HH:MM:SS)
Column D: status (enum: active, inactive, suspended)
Column E: credit_balance (number, ≥0)
Column F: metadata (text, JSON object)

In the Sheets UI, select each column and add data validation (Data → Validation). For Column D, restrict to a list: active, inactive, suspended. For Column E, require numbers ≥ 0. This won't prevent bad data via the API, but it catches manual entry mistakes.

In your code, validate before writing:

function validateRecord(record) {
  if (!record.user_id || typeof record.user_id !== 'string') {
    throw new Error('user_id required and must be string');
  }
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(record.email)) {
    throw new Error('invalid email format');
  }
  if (!['active', 'inactive', 'suspended'].includes(record.status)) {
    throw new Error('status must be active, inactive, or suspended');
  }
  if (typeof record.credit_balance !== 'number' || record.credit_balance < 0) {
    throw new Error('credit_balance must be number ≥ 0');
  }
  return true;
}

Querying without pulling everything

The Sheets API doesn't support SQL-like queries. You have two options:

Option 1: Pull the whole range and filter in-memory. Fine for <10,000 rows. Slow and wasteful beyond that.

const rows = await readDatabase();
const active = rows.filter(r => r[3] === 'active');

Option 2: Use Google Apps Script to pre-compute views. Write a script that runs on a schedule, filters the data, and writes results to a separate sheet.

function updateActiveUsers() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet();
  const sourceRange = sheet.getSheetByName('Users').getDataRange();
  const values = sourceRange.getValues();
  
  const filtered = values.filter(row => row[3] === 'active');
  const targetSheet = sheet.getSheetByName('ActiveUsers');
  targetSheet.clearContents();
  targetSheet.getRange(1, 1, filtered.length, filtered[0].length).setValues(filtered);
}

Schedule this via Apps Script's trigger system (Edit → Current project's triggers) to run every 6 hours. Now your API reads from ActiveUsers instead of the full table.

Caching and API quotas

Google Sheets API has a quota of 60 requests per minute per user. If you're doing frequent reads, you'll hit it. Solution: cache aggressively.

Stash the Sheets data in Redis or even a local JSON file, refresh every 30 minutes, and serve from cache:

const NodeCache = require('node-cache');
const cache = new NodeCache({stdTTL: 1800}); // 30 minutes

async function getCachedDatabase() {
  let data = cache.get('sheets_data');
  if (!data) {
    data = await readDatabase();
    cache.set('sheets_data', data);
  }
  return data;
}

This buys you a 30-minute staleness window in exchange for near-instant reads and staying well under quota.

When to migrate away

Move to a proper database the moment:

  • You're writing more than 100 records per day.
  • You need concurrent writes (Sheets serialises them).
  • Your queries require joins or aggregations across millions of cells.
  • You're caching more than 50% of reads because live queries are too slow.
  • You need transactions or rollbacks.

PostgreSQL (via Render, Railway, or Supabase) costs $7–15/month and eliminates all these pain points. Sheets saved you setup time; now it's costing you debugging time.

What I'd actually do

Use Google Sheets as a database only if you're prototyping a tool that'll live for 3–6 months and you need zero DevOps friction. If you're a solo developer, pairing this kind of lightweight approach with the right productivity tools solo developer setups can stretch that runway even further. Build the Sheets integration using the pattern above, add caching from day one, and document your schema clearly.

The moment you're tempted to add a second sheet for "derived data" or write a complex Apps Script to work around Sheets' limitations, you've already lost. Migrate to PostgreSQL and reclaim the hours you'll spend fighting the tool. If you're unsure which editor and toolchain to standardise on before that migration, the comparison on techjournaler.com is worth a read.

Sheets is a spreadsheet. It's brilliant at that job. Don't ask it to be a database.