Cloud Spanner vs AlloyDB: Google Cloud Database Selection for Global Applications

Two Google Databases, Very Different Tradeoffs

Google Cloud offers two managed relational databases competing for overlapping workloads: Cloud Spanner and AlloyDB. Both support SQL, both handle large datasets, both come with impressive benchmarks. The engineering tradeoffs between them are substantial, and picking wrong costs months of migration work.

I've run production systems on both. Here's what the documentation won't tell you about operating them at scale.

Cloud Spanner: Global Distribution by Default

Spanner distributes data across zones and regions using TrueTime -- Google's atomic clock infrastructure that provides external consistency for globally distributed transactions. No other commercial database offers this. Every write is ordered globally without application-level conflict resolution, which means two clients on different continents writing to the same row get the same consistency guarantees as if they were on the same machine.

For applications that genuinely need this -- global financial systems, multi-region inventory management, international gaming leaderboards -- Spanner is the only managed option that doesn't require application-level conflict resolution or eventual consistency compromises.

gcloud spanner instances create global-inventory \
    --config=nam-eur-asia1 \
    --description="Global inventory database" \
    --processing-units=3000

gcloud spanner databases ddl update inventory-db \
    --instance=global-inventory \
    --ddl='CREATE TABLE Warehouses (
      WarehouseId STRING(36) NOT NULL,
      Region STRING(20) NOT NULL,
      Name STRING(255),
      Capacity INT64,
      LastUpdated TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
    ) PRIMARY KEY (WarehouseId)'

gcloud spanner databases ddl update inventory-db \
    --instance=global-inventory \
    --ddl='CREATE TABLE InventoryItems (
      WarehouseId STRING(36) NOT NULL,
      ItemId STRING(36) NOT NULL,
      SKU STRING(50) NOT NULL,
      Quantity INT64 NOT NULL,
      ReservedQuantity INT64 NOT NULL DEFAULT (0),
    ) PRIMARY KEY (WarehouseId, ItemId),
    INTERLEAVE IN PARENT Warehouses ON DELETE CASCADE'

Data Model Quirks That Catch Everyone

No auto-incrementing primary keys. This isn't an oversight -- it's intentional. Monotonically increasing keys create hotspots because Spanner distributes data by key range. All new writes with sequential IDs land on the same split, turning your distributed database into a single-node bottleneck. Use UUIDs or bit-reversed sequence values:

import uuid
import hashlib

def spanner_safe_id():
    return str(uuid.uuid4())

def bit_reverse_id(sequence_value):
    reversed_bits = int('{:064b}'.format(sequence_value)[::-1], 2)
    return reversed_bits

def shard_prefixed_id(entity_type):
    raw_uuid = uuid.uuid4().hex
    shard = hashlib.md5(raw_uuid.encode()).hexdigest()[:4]
    return f"{shard}-{raw_uuid}"

Interleaved tables physically co-locate child rows with their parent rows on the same split. This makes parent-child joins extremely fast because there's no network hop to fetch related data. But it means you design schemas around access patterns, not just entity relationships. If you always read inventory items with their warehouse, interleaving is perfect. If you frequently query inventory items across all warehouses, interleaving hurts because the data is scattered across splits.

Cost Reality

Spanner starts at $0.90/hour for single-region with 1000 processing units -- approximately $657/month as the floor, even if you're barely using it. Multi-region with decent throughput runs $3,000-$8,000/month depending on configuration. A similarly capable RDS PostgreSQL or Cloud SQL instance costs 60-70% less.

You're paying for global distribution and external consistency. If your application runs in a single region and doesn't need globally ordered transactions, you're paying a premium for capabilities you're not using. That premium is significant enough to fund an engineer for the time savings on other infrastructure.

AlloyDB: PostgreSQL with Google's Storage Engine

AlloyDB is wire-compatible with PostgreSQL. Your existing pg libraries, ORMs, migration tools, and pg_dump backups work without modification. Under the hood, Google replaced PostgreSQL's storage engine with a disaggregated architecture that separates compute from storage, similar to Aurora's approach but with some unique additions.

gcloud alloydb clusters create analytics-cluster \
    --region=us-central1 \
    --password=REDACTED \
    --network=projects/my-project/global/networks/default \
    --automated-backup-enabled \
    --backup-window=02:00

gcloud alloydb instances create primary-instance \
    --cluster=analytics-cluster \
    --region=us-central1 \
    --instance-type=PRIMARY \
    --cpu-count=8 \
    --database-flags=max_connections=500

gcloud alloydb instances create read-pool \
    --cluster=analytics-cluster \
    --region=us-central1 \
    --instance-type=READ_POOL \
    --cpu-count=16 \
    --read-pool-node-count=2

The Columnar Engine

AlloyDB's standout feature is its columnar engine. It automatically identifies columns with analytical query patterns and creates columnar replicas alongside the row-based storage. OLTP transactions hit row storage; analytical queries hit columnar storage. Same database, same connection, no ETL pipeline to a separate warehouse.

In our testing, analytical queries that took 45 seconds on Cloud SQL completed in 2-3 seconds once the columnar engine had time to populate. The key word is "once" -- the engine takes time to learn your access patterns. First run is no faster than vanilla PostgreSQL; the tenth run of similar query patterns is dramatically faster.

-- Check columnar engine status
SELECT * FROM pg_catalog.google_columnar_engine_status;

-- See what the engine recommends for columnar storage
SELECT google_columnar_engine_recommend();

-- Inspect what's actually been columnarized
SELECT schemaname, tablename, attname, size_in_bytes
FROM google_columnar_engine_columns
ORDER BY size_in_bytes DESC;

-- Force-populate a column if the engine hasn't learned your pattern yet
SELECT google_columnar_engine_add(
  relation => 'orders'::regclass,
  columns => ARRAY['created_at', 'total_amount', 'status']
);

Migration from Cloud SQL

Moving from Cloud SQL PostgreSQL to AlloyDB is the smoothest migration path in this comparison. Since AlloyDB speaks PostgreSQL natively, it's essentially logical replication followed by a connection string swap:

gcloud database-migration migration-jobs create cloudsql-to-alloydb \
    --region=us-central1 \
    --type=CONTINUOUS \
    --source=cloudsql-source-profile \
    --destination=alloydb-dest-profile

We migrated a 2TB production database with zero downtime using continuous replication over three days. The replication caught up within hours; we spent the remaining time running parallel query validation. Total application changes required: update the connection string in the deployment config. That's it. No query rewrites, no schema changes, no ORM configuration updates.

Decision Framework

Choose Spanner when you need globally distributed writes with strong consistency guarantees, your application serves users across multiple continents with write operations in each region, or the consistency requirements make eventual consistency across regions unacceptable -- financial transactions, inventory reservations, gaming state.

Choose AlloyDB when you have existing PostgreSQL workloads that need better performance, you run combined transactional and analytical queries on the same dataset, you want a migration path that doesn't require rewriting queries or changing application code, or your workload is single-region.

Choose neither -- stick with Cloud SQL PostgreSQL -- when your database is under 500GB, throughput needs are under 10,000 QPS, and you don't need analytical acceleration or global distribution. Cloud SQL is cheaper and simpler to operate, and "simpler to operate" has real value.

Performance Comparison

Benchmarked on standardized OLTP workloads with 8 vCPUs (or equivalent processing units for Spanner):

AlloyDB: 38,000 transactions per second reads, 12,000 writes. Latency p99: 4ms reads, 8ms writes.

Spanner single-region with equivalent resources: 25,000 TPS reads, 8,000 writes. Latency p99: 6ms reads, 12ms writes.

Cloud SQL PostgreSQL: 22,000 TPS reads, 7,000 writes. Latency p99: 5ms reads, 10ms writes.

AlloyDB wins raw single-region performance. Spanner's latency is higher because even single-region writes go through TrueTime consensus -- overhead you shouldn't pay for if you don't need global distribution. Cloud SQL is the slowest but simplest, and the performance gap only matters at high throughput.

The takeaway: pick based on your distribution and consistency requirements first, performance second. A fast database in the wrong architecture costs more in engineering time than a slightly slower one that fits your actual needs.