Every studio that ships a competitive game eventually hits the same wall: the leaderboard works fine in one region, then falls apart the moment players in Tokyo, Frankfurt, and Virginia all submit scores in the same second. Rank order gets fuzzy. Two players see different “#1” finishers. Support tickets pile up. That’s the exact failure mode Google built Cloud Spanner to prevent, and in October 2026 it’s become a realistic option even for mid-size studios, not just companies with Google-scale budgets.
This tutorial walks through building a global, strongly consistent game leaderboard and player profile database on Cloud Spanner, end to end. You’ll provision a real instance, design a schema that avoids the classic Spanner hotspotting mistake, write working Node.js and Python code, load test data, and wire up multi-region replication so a score submitted in Belgium and a score submitted in Oregon land in the same consistent order every time. By the end you’ll have a running backend you could actually plug into a game client.
Don't miss new tech stories on Google
Add Tech Insider once in the Google app and our stories appear in your news suggestions.
Why Cloud Spanner for Game Leaderboards, Not DynamoDB or Redis
Most game backend tutorials reach for DynamoDB or a Redis sorted set for leaderboards, and for single-region games that’s often the right call â our own walkthrough of a serverless AWS Lambda and DynamoDB leaderboard covers that path well. The problem shows up at global scale. DynamoDB Global Tables replicate across regions using last-writer-wins conflict resolution, which means two near-simultaneous writes to the same item in different regions can silently overwrite each other based on timestamp, not business logic. For a leaderboard that’s usually harmless. For a player’s currency balance, inventory, or match result, it can mean lost writes.
Cloud Spanner takes a different approach. It’s a horizontally scalable relational database that gives you ACID transactions and external consistency across regions, not just within one. Google has run Spanner internally since 2012, and it’s the database behind Google’s own ad platform, which generates the bulk of Alphabet’s revenue, according to Google Cloud’s own documentation. That track record matters less for a game studio than the practical guarantee: when Spanner commits a transaction, every subsequent read anywhere in the world sees that commit. No eventual-consistency window, no stale leaderboard rank.
The tradeoff is cost and operational complexity. Spanner isn’t the cheapest option for a small indie game with a few thousand daily players, and the schema design rules (especially around interleaved tables and key distribution) are stricter than a document database. This tutorial is aimed at teams building a live-service or competitive game where leaderboard correctness and global write consistency actually matter, not every mobile puzzle game with a local high-score table.
How Spanner’s TrueTime Clock Makes Global Consistency Possible
It’s worth understanding, at a high level, why Spanner can make this guarantee when most distributed databases can’t. The short version: Spanner relies on a Google-internal system called TrueTime, which synchronizes clocks across every machine in a Spanner deployment to within a tight, bounded uncertainty window using atomic clocks and GPS receivers in Google’s data centers. When Spanner commits a transaction, it waits out that uncertainty window before acknowledging the commit, which guarantees that any later transaction, anywhere in the world, is assigned a timestamp that’s unambiguously “after” the first one.
That’s the mechanism behind “external consistency,” Spanner’s term for a stronger guarantee than the “strong consistency” label most databases use loosely. It means the order of commits as observed by any client matches the real-world order in which they happened, not just a consistent-but-arbitrary order picked by the database. For a leaderboard, this is what prevents the scenario from Step 9: a lower score committed a few milliseconds earlier in one region can’t end up ranked above a higher score committed a few milliseconds later in another region, because the transaction logic reads the current value before deciding whether to overwrite it, and TrueTime guarantees that read sees every commit that genuinely happened first.
You don’t have to configure TrueTime yourself â it’s part of the managed service, which is exactly the appeal for a game studio that doesn’t want to build and operate its own clock-synchronization infrastructure just to get a correct global leaderboard. The tradeoff is the commit latency mentioned in Step 4: waiting out the uncertainty window is part of why multi-region commits take longer than single-region ones, and it’s a cost you’re paying for a guarantee rather than an accident of network distance.
Spanner vs. DynamoDB vs. Cosmos DB for Game Backends
Each of the three major cloud databases that come up in game-backend discussions handles cross-region consistency differently, and the differences are the actual reason to pick one over another, not raw feature count. The table below summarizes the consistency model each one defaults to and what you have to opt into to get stronger guarantees.
| Database | Default consistency model | Strong cross-region consistency | Primary pricing model |
|---|---|---|---|
| Cloud Spanner | External consistency (strongest available) | Built in on every multi-region config, no opt-in required | Processing units/node-hour, pay-as-you-go or committed use |
| DynamoDB (Global Tables) | Eventually consistent across regions; last-writer-wins conflict resolution | Not available across regions; single-region transactions only | On-demand or provisioned read/write capacity units |
| Azure Cosmos DB | Configurable, five levels from eventual to strong | Available only within a single write region unless using the slower “strong” consistency level globally, which Microsoft’s own documentation notes adds meaningful write latency | Request units (RU/s) provisioned or serverless |
For a deeper dive into how Spanner’s pricing and consistency model stack up against other distributed SQL options, see our Aurora DSQL vs. CockroachDB vs. Spanner comparison; for a document-database angle on the same tradeoffs, our DynamoDB vs. MongoDB breakdown is a useful companion read. The practical takeaway for a game studio: if your leaderboard, in-game economy, or match-result data genuinely needs the same correctness guarantee in every region without any extra configuration, Spanner is the one built around that requirement from the ground up. If you can tolerate eventual consistency and last-writer-wins for your use case, which is genuinely fine for a lot of non-competitive or single-region games, DynamoDB or Cosmos DB’s default settings will usually be cheaper to operate and have a shallower learning curve. The schema-design discipline this tutorial covers in Step 5 â specifically avoiding monotonic keys â is also stricter on Spanner than on either alternative, since DynamoDB and Cosmos DB both handle key distribution more automatically at the cost of the consistency guarantee.
Prerequisites and Versions
Confirm these versions before starting. Spanner’s gcloud surface changes often enough that an outdated CLI will throw confusing errors on flags that were renamed or deprecated.
| Requirement | Version / Detail | Notes |
|---|---|---|
| Google Cloud account | Active billing account | Spanner has no fully free standing tier outside the 90-day trial instance |
| gcloud CLI | Latest stable (run gcloud components update) | Spanner commands live under gcloud spanner |
| Node.js | 18.x or later | Required by @google-cloud/spanner client |
| @google-cloud/spanner (npm) | 9.x | Check the GitHub releases page for the exact current tag before pinning |
| Python | 3.10 – 3.14 | google-cloud-spanner 3.71.0 dropped support for Python versions below 3.10 |
| google-cloud-spanner (PyPI) | 3.71.0 (released September 3, 2026) | Verify against the PyPI project page |
| Docker (optional) | 24.x+ | Only needed if you want to run the Spanner emulator locally |
You’ll also want a project with billing enabled, since even the smallest regional Spanner instance bills by processing units per hour rather than being free indefinitely. Budget for roughly $0.09 an hour while you’re actively testing on a Standard-edition regional instance at the minimum 100 processing units, and remember to tear it down when you’re done with this tutorial.
Step 1: Create a Google Cloud Project and Enable the Spanner API
Start from a clean project so billing and IAM stay easy to audit later. If you already have a project you want to reuse, skip to enabling the API.
gcloud projects create game-leaderboard-demo --name="Game Leaderboard Demo"
gcloud config set project game-leaderboard-demo
# Link a billing account (replace with your actual billing account ID)
gcloud billing projects link game-leaderboard-demo \
--billing-account=XXXXXX-XXXXXX-XXXXXX
# Enable the Spanner API
gcloud services enable spanner.googleapis.com
If the billing link command fails, it almost always means your user account isn’t a Billing Account Administrator on that billing account. Check in the Cloud Console under Billing > Account Management, or ask whoever owns the org’s billing to grant you the role.
Step 2: Decide Standard vs. Enterprise vs. Enterprise Plus
Spanner’s 2026 pricing model splits into three editions, and picking the wrong one either overpays or locks you out of features you’ll want. According to Google Cloud’s current pricing page, Standard edition starts at $0.09 per 100 processing units per hour per replica, Enterprise is $0.123, and Enterprise Plus is $0.171. In node terms (1 node equals 1,000 processing units), that’s roughly $0.90, $1.23, and $1.71 per node-hour respectively, before any committed-use discount.
| Edition | Price per 100 PU/hour | Price per node-hour | Key features |
|---|---|---|---|
| Standard | $0.09 | $0.90 | Core relational engine, single-region or multi-region configs |
| Enterprise | $0.123 | $1.23 | Adds Spanner Graph, full-text search, vector search (KNN/ANN) |
| Enterprise Plus | $0.171 | $1.71 | Everything in Enterprise, plus up to 99.999% availability SLA and geo-partitioning |
For a leaderboard-and-player-profile database, you don’t strictly need Enterprise’s graph or vector search features unless you’re also building a friend-recommendation or similar-player-matching system on the same database. Standard edition with a multi-region configuration covers the consistency guarantee this tutorial is built around. Enterprise Plus becomes worth the premium if you’re running a live competitive title where even a few minutes of downtime during a tournament is a real financial or reputational cost â it’s the tier that pushes the SLA from 99.99% up toward 99.999%.
One more pricing detail worth knowing before you provision anything: Google offers one-year and three-year committed-use discounts on every edition. Per the current pricing page, a one-year commitment drops Standard to $0.72 per node-hour and Enterprise to $0.984; a three-year commitment drops them to $0.54 and $0.738. If you already know this leaderboard is going into production rather than staying a prototype, it’s worth calculating the break-even against on-demand pricing before launch.
Step 3: Spin Up a 90-Day Free Trial Instance First
Before you commit real budget, use Google’s free trial instance to build and test the schema. Per Google Cloud’s documentation, the free trial instance runs at no cost for up to 90 days, supports up to 10 GiB of stored data, and works with both GoogleSQL and PostgreSQL-dialect databases. It also supports a meaningful slice of Enterprise-tier capabilities during the trial, including KNN/ANN vector-distance functions, full-text search, and Spanner Graph, so you can experiment with those even if you plan to run Standard edition in production.
# Create the free trial instance (one per project, GoogleSQL dialect)
gcloud spanner instances create game-leaderboard-trial \
--config=free-instance \
--description="Leaderboard trial instance" \
--edition=STANDARD
# Verify it was created and check its state
gcloud spanner instances describe game-leaderboard-trial
The trial instance is capped to one per Cloud project and can’t be resized into a paid multi-region config in place â when you’re ready for production, you provision a separate paid instance and migrate the schema and data over. Treat the trial strictly as a design sandbox.
Step 4: Provision the Production Instance With a Multi-Region Config
For the real leaderboard, you need a multi-region instance configuration, not a regional one. This is the step that actually buys you the global consistency guarantee â a regional instance is strongly consistent within that region but doesn’t solve the cross-continent problem this tutorial opened with.
# List available multi-region configs to pick one close to your player base
gcloud spanner instance-configs list
# Create a production instance using a multi-region config
# nam-eur-asia1 spans North America, Europe, and Asia -- a common choice
# for global competitive titles
gcloud spanner instances create game-leaderboard-prod \
--config=nam-eur-asia1 \
--description="Production game leaderboard" \
--processing-units=1000 \
--edition=STANDARD
gcloud spanner databases create leaderboard-db \
--instance=game-leaderboard-prod \
--database-dialect=GOOGLE_STANDARD_SQL
1,000 processing units (one full node) is a reasonable starting point for a mid-size live-service game; you can scale processing units up or down later without downtime, which is one of Spanner’s real operational advantages over manually resharded systems. Multi-region configs cost more than regional ones because every write has to achieve quorum across regions before committing â that’s the mechanism providing the consistency guarantee, and it’s also why write latency on a multi-region instance (typically 80-130ms for a commit, depending on the exact region spread) will run higher than a single-region instance. Design your client code to tolerate that, rather than assuming leaderboard writes are instant.
Step 5: Design the Schema â Avoid the Hotspot Trap
This is the step where most new Spanner users get burned, and it’s specific to how Spanner physically distributes data. Spanner splits tables into ranges based on the primary key, and if your primary key is monotonically increasing (an auto-incrementing integer, or a timestamp placed first in a composite key), every new write lands on the same split. That split becomes a hotspot: one server doing all the work while the rest of your cluster sits idle, no matter how many processing units you’ve provisioned.
The fix is to lead your primary key with something well-distributed. For a leaderboard, a season or shard identifier works well, with the player ID providing further spread. Here’s the schema:
CREATE TABLE Players (
PlayerId STRING(36) NOT NULL,
DisplayName STRING(64) NOT NULL,
Region STRING(8) NOT NULL,
CreatedAt TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
) PRIMARY KEY (PlayerId);
CREATE TABLE Seasons (
SeasonId STRING(16) NOT NULL,
StartsAt TIMESTAMP NOT NULL,
EndsAt TIMESTAMP NOT NULL,
) PRIMARY KEY (SeasonId);
CREATE TABLE LeaderboardEntries (
SeasonId STRING(16) NOT NULL,
PlayerId STRING(36) NOT NULL,
Score INT64 NOT NULL,
MatchCount INT64 NOT NULL DEFAULT (0),
UpdatedAt TIMESTAMP NOT NULL OPTIONS (allow_commit_timestamp=true),
) PRIMARY KEY (SeasonId, PlayerId),
INTERLEAVE IN PARENT Seasons ON DELETE CASCADE;
CREATE INDEX LeaderboardByScore
ON LeaderboardEntries (SeasonId, Score DESC)
STORING (PlayerId, MatchCount);
Interleaving LeaderboardEntries inside Seasons is the right call here because every leaderboard row has a strong parent-lifetime relationship to its season â when a season ends and gets deleted, its entries should go with it, and Spanner physically colocates interleaved child rows next to their parent, which makes “give me the top 100 for this season” a cheap, local read instead of a scatter-gather across the cluster. The secondary index on (SeasonId, Score DESC) is what actually makes rank queries fast; without it, Spanner would have to scan every entry in a season to sort by score.
Apply the schema using the gcloud DDL command:
gcloud spanner databases ddl update leaderboard-db \
--instance=game-leaderboard-prod \
--ddl-file=schema.sql
Step 6: Write the Node.js Leaderboard Service
Install the client library and set up a connection pool. Spanner’s Node.js client handles session pooling internally, but it’s worth being explicit about pool size for a service that’s going to see bursty traffic, like everyone submitting scores right after a tournament round ends.
npm install @google-cloud/spanner
// leaderboard.js
const { Spanner } = require('@google-cloud/spanner');
const spanner = new Spanner({ projectId: 'game-leaderboard-demo' });
const instance = spanner.instance('game-leaderboard-prod');
const database = instance.database('leaderboard-db');
async function submitScore(seasonId, playerId, score) {
await database.runTransactionAsync(async (transaction) => {
const [rows] = await transaction.read('LeaderboardEntries', {
keys: [[seasonId, playerId]],
columns: ['Score', 'MatchCount'],
});
if (rows.length === 0) {
transaction.insert('LeaderboardEntries', {
SeasonId: seasonId,
PlayerId: playerId,
Score: score,
MatchCount: 1,
UpdatedAt: Spanner.COMMIT_TIMESTAMP,
});
} else {
const current = rows[0].toJSON();
const bestScore = Math.max(current.Score, score);
transaction.update('LeaderboardEntries', {
SeasonId: seasonId,
PlayerId: playerId,
Score: bestScore,
MatchCount: Number(current.MatchCount) + 1,
UpdatedAt: Spanner.COMMIT_TIMESTAMP,
});
}
await transaction.commit();
});
}
async function getTopPlayers(seasonId, limit = 100) {
const query = {
sql: `SELECT PlayerId, Score, MatchCount
FROM LeaderboardEntries
WHERE SeasonId = @seasonId
ORDER BY Score DESC
LIMIT @limit`,
params: { seasonId, limit },
};
const [rows] = await database.run(query);
return rows.map((row) => row.toJSON());
}
module.exports = { submitScore, getTopPlayers };
The read-then-write pattern inside runTransactionAsync matters: it’s what gives you a correct “keep the higher score” update even when two score submissions for the same player race each other from different regions. Spanner’s transaction will abort and retry one of them automatically rather than letting a lower score silently clobber a higher one, which is the exact class of bug that last-writer-wins replication can’t prevent.
Step 7: Write the Python Equivalent
If your backend services are Python (common for studios running analytics or matchmaking logic alongside the game server â see our real-time game analytics pipeline tutorial for the AWS equivalent), the same pattern looks like this using google-cloud-spanner 3.71.0:
pip install google-cloud-spanner==3.71.0
# leaderboard.py
from google.cloud import spanner
client = spanner.Client(project="game-leaderboard-demo")
instance = client.instance("game-leaderboard-prod")
database = instance.database("leaderboard-db")
def submit_score(season_id, player_id, score):
def update_entry(transaction):
row = transaction.read(
table="LeaderboardEntries",
columns=("Score", "MatchCount"),
keyset=spanner.KeySet(keys=[[season_id, player_id]]),
)
rows = list(row)
if not rows:
transaction.insert(
table="LeaderboardEntries",
columns=("SeasonId", "PlayerId", "Score", "MatchCount", "UpdatedAt"),
values=[(season_id, player_id, score, 1, spanner.COMMIT_TIMESTAMP)],
)
else:
current_score, match_count = rows[0]
best_score = max(current_score, score)
transaction.update(
table="LeaderboardEntries",
columns=("SeasonId", "PlayerId", "Score", "MatchCount", "UpdatedAt"),
values=[(season_id, player_id, best_score, match_count + 1, spanner.COMMIT_TIMESTAMP)],
)
database.run_in_transaction(update_entry)
def get_top_players(season_id, limit=100):
with database.snapshot() as snapshot:
results = snapshot.execute_sql(
"SELECT PlayerId, Score, MatchCount FROM LeaderboardEntries "
"WHERE SeasonId = @season_id ORDER BY Score DESC LIMIT @limit",
params={"season_id": season_id, "limit": limit},
param_types={
"season_id": spanner.param_types.STRING,
"limit": spanner.param_types.INT64,
},
)
return list(results)
Note the use of a read-only snapshot() for the leaderboard query rather than a full transaction. Reads don’t need to take locks, and snapshot reads are measurably cheaper on Spanner’s side because they skip the two-phase commit path entirely â reserve full transactions for the read-modify-write score update.
Step 8: Load Test Data and Confirm Index Behavior
Before trusting the schema, push enough synthetic data through it to confirm the secondary index is actually being used for rank queries rather than a full scan. A quick seeding script:
// seed.js
const { submitScore } = require('./leaderboard');
async function seed() {
const regions = ['nam', 'eur', 'asia'];
for (let i = 0; i < 5000; i++) {
const playerId = `player-${i}-${regions[i % 3]}`;
const score = Math.floor(Math.random() * 100000);
await submitScore('season-2026-q4', playerId, score);
if (i % 500 === 0) console.log(`Seeded ${i} players`);
}
}
seed().then(() => console.log('Done')).catch(console.error);
Run it, then check the query plan to confirm the index gets used:
gcloud spanner databases execute-sql leaderboard-db \
--instance=game-leaderboard-prod \
--sql="EXPLAIN SELECT PlayerId, Score FROM LeaderboardEntries WHERE SeasonId = 'season-2026-q4' ORDER BY Score DESC LIMIT 100"
Expected output should show an Index Scan against LeaderboardByScore, not a Table Scan. If you see a Table Scan instead, double-check the index was created and that your query’s ORDER BY clause matches the index’s column order exactly â Spanner won’t use an index for sorting if the sort order doesn’t align with how the index is defined.
Step 9: Test Cross-Region Consistency Directly
The whole point of this setup is consistency across regions, so prove it rather than assuming it. Run this from two different machines (or two Cloud Shell sessions) at roughly the same time:
# Machine 1 (simulating a US write)
node -e "require('./leaderboard').submitScore('season-2026-q4', 'player-race-test', 9000).then(() => console.log('write A done'))"
# Machine 2 (simulating an EU write, fired within milliseconds)
node -e "require('./leaderboard').submitScore('season-2026-q4', 'player-race-test', 9500).then(() => console.log('write B done'))"
# Then immediately read from a third location
node -e "require('./leaderboard').getTopPlayers('season-2026-q4', 5).then(r => console.log(JSON.stringify(r, null, 2)))"
Because both writes target the same primary key (the same SeasonId/PlayerId pair), Spanner serializes them through its transaction manager rather than letting them land as independent replicated writes. The read-modify-write logic in submitScore means the surviving value is always the higher score of the two (9500), regardless of which write physically committed first at the storage layer. That’s the behavior a last-writer-wins system can’t guarantee â there, whichever write has the later wall-clock timestamp wins, even if it’s the lower score.
Step 10: Add IAM Roles for Your Game Server Service Account
If you’re weighing Google Cloud against AWS or Azure for this project overall, our AWS vs. Azure vs. Google Cloud comparison covers the broader platform tradeoffs. Your game servers shouldn’t authenticate with your personal gcloud credentials in production. Create a dedicated service account scoped to exactly the Spanner permissions it needs:
gcloud iam service-accounts create leaderboard-service \
--display-name="Leaderboard Game Server"
gcloud projects add-iam-policy-binding game-leaderboard-demo \
--member="serviceAccount:leaderboard-service@game-leaderboard-demo.iam.gserviceaccount.com" \
--role="roles/spanner.databaseUser"
# Generate a key for local testing only -- use Workload Identity in production
gcloud iam service-accounts keys create leaderboard-key.json \
--iam-account=leaderboard-service@game-leaderboard-demo.iam.gserviceaccount.com
roles/spanner.databaseUser grants read and write access without the ability to alter schema or delete the instance, which limits blast radius if a game server is compromised. If your game servers run on Google Kubernetes Engine, skip the downloaded JSON key entirely and bind the service account through Workload Identity instead â a static key file sitting in a container image is one of the more common ways Spanner credentials end up leaked.
Step 11: Set Up Monitoring for Hotspots and Latency
Spanner exports metrics to Cloud Monitoring automatically, but the default dashboards don’t highlight the specific thing you care about here: is any single split getting overloaded. Create an alert on CPU utilization per node and on lock wait time, since both spike sharply when a hotspot forms.
gcloud alpha monitoring policies create \
--notification-channels=CHANNEL_ID \
--display-name="Spanner High CPU" \
--condition-display-name="Instance CPU > 65%" \
--condition-filter='resource.type="spanner_instance" AND metric.type="spanner.googleapis.com/instance/cpu/utilization"' \
--condition-threshold-value=0.65 \
--condition-threshold-duration=300s
65% is a conservative threshold that gives you headroom to react before a traffic spike (a tournament finale, a patch-day login surge) pushes the instance into saturation. If you see CPU climb while processing units stay flat, that’s usually a hotspot forming from a bad key choice rather than genuinely needing more capacity â check the query plan again before reaching for the scale-up button.
Step 12: Build a Simple REST Wrapper for Your Game Client
Game clients shouldn’t talk to Spanner directly â wrap it in a small REST or gRPC service so you can add rate limiting, auth, and anti-cheat score validation in front of the database. Our Google Cloud Run setup guide covers deploying exactly this kind of wrapper service as a container. A minimal Express wrapper:
// server.js
const express = require('express');
const { submitScore, getTopPlayers } = require('./leaderboard');
const app = express();
app.use(express.json());
app.post('/seasons/:seasonId/scores', async (req, res) => {
const { seasonId } = req.params;
const { playerId, score } = req.body;
if (typeof score !== 'number' || score < 0 || score > 1000000) {
return res.status(400).json({ error: 'Invalid score range' });
}
await submitScore(seasonId, playerId, score);
res.status(204).send();
});
app.get('/seasons/:seasonId/top', async (req, res) => {
const limit = Math.min(Number(req.query.limit) || 100, 500);
const results = await getTopPlayers(req.params.seasonId, limit);
res.json(results);
});
app.listen(8080, () => console.log('Leaderboard API on :8080'));
The hard cap on score range and the 500-row cap on leaderboard reads aren’t decoration â they’re the first line of defense against both obviously spoofed scores and a client accidentally (or maliciously) requesting a leaderboard dump large enough to drive up your read costs.
Step 13: Tear Down or Right-Size for Production Traffic
If this was a test run, delete the trial and prod instances so billing stops:
gcloud spanner instances delete game-leaderboard-trial --quiet
gcloud spanner instances delete game-leaderboard-prod --quiet
If you’re moving toward an actual launch, instead right-size processing units against real load. Spanner recommends keeping average CPU utilization under roughly 65% for regional instances and under 45% for multi-region instances, since multi-region configs need more headroom to absorb a regional failover without the remaining regions getting overloaded. Scale processing units up before a known traffic event (a tournament, a content drop) and back down afterward â Spanner applies the change without downtime, so there’s little reason to keep headroom provisioned 24/7.
Common Pitfalls When Building Game Backends on Spanner
These are the mistakes that come up repeatedly in Spanner migrations, not hypothetical edge cases:
- Leading the primary key with an auto-incrementing ID or raw timestamp. This creates the hotspot problem covered in Step 5 and is the single most common reason a new Spanner schema performs badly under load.
- Treating multi-region as a free upgrade. It isn’t. Write latency goes up because of the cross-region quorum requirement, and cost per processing unit is the same across editions but you typically need more processing units to hit the same throughput once writes have to travel farther.
- Running every read inside a read-write transaction. Read-write transactions take locks; a leaderboard view that only reads should use a snapshot read, which is both faster and cheaper.
- Forgetting
allow_commit_timestamp=trueon timestamp columns meant to track write time. Without that option, you can’t useSpanner.COMMIT_TIMESTAMPas a value, and you’ll fall back to client-side clocks that can drift between regions. - Interleaving tables that don’t share a real parent-child lifetime. Interleaving is for data that’s always read together and deleted together; forcing an unrelated table into an interleaved hierarchy just to “try it” usually hurts more than it helps.
- Provisioning a trial instance and expecting it to become production. The free trial instance is capped at 10 GiB and can’t be upgraded in place to a paid multi-region config â plan the migration path from day one.
- Skipping the query plan check. A schema that looks correct on paper can silently fall back to a table scan if a secondary index’s column order doesn’t match the query’s sort order, and you won’t notice until the data volume is large enough to matter.
- Shipping a static service account key inside a container image. It’s the easiest Spanner credential to leak and the easiest one to avoid, since Workload Identity on GKE or attached service accounts on Compute Engine remove the need for a downloaded key entirely.
Troubleshooting Common Spanner Errors
Expect to run into at least a few of these while building this out:
- “Aborted: Transaction was aborted” â Normal under contention. Spanner’s client libraries retry aborted transactions automatically inside
runTransactionAsyncandrun_in_transaction; if you’re seeing it surface as an unhandled error, you’ve likely wrapped the transaction logic incorrectly or disabled automatic retries. - “RESOURCE_EXHAUSTED: Database is overloaded” â Usually means a hotspot, not genuinely insufficient capacity. Check the query plan and your primary key design before scaling processing units.
- “PERMISSION_DENIED” on
spanner.databases.selectâ The service account is missingroles/spanner.databaseUseror an equivalent custom role; re-check the IAM binding from Step 10. - “INVALID_ARGUMENT: Invalid DDL statement” â Check for a missing semicolon between CREATE TABLE statements in your DDL file, or a column type Spanner doesn’t support in the dialect you chose (GoogleSQL vs. PostgreSQL interface have slightly different type names).
- Billing account link command fails silently â Confirm you’re a Billing Account Administrator, not just a project owner; those are separate IAM scopes.
- Index not used despite matching the WHERE clause â Check that the
ORDER BYdirection matches the index definition (DESCvs.ASC); a mismatch silently falls back to a table scan plus sort. - High write latency on a multi-region instance â Expected behavior, not a bug. Confirm your region spread in the instance config isn’t wider than your actual player base needs; a nam-eur-asia config pays a consistency tax even for players who never leave one continent.
- Free trial instance creation fails with “already exists” â Each Cloud project can only have one free trial instance ever, even after deleting the first one. Create a new project if you need a second trial sandbox.
Advanced Tip: Combine Spanner Graph With Your Leaderboard for Social Features
If you upgrade to Enterprise edition, Spanner Graph lets you model friend relationships and party groupings in the same database as your leaderboard, queried with a graph-native syntax rather than recursive SQL joins. That matters for a feature like “show me how my friends rank relative to me” without standing up a separate graph database and keeping two systems in sync. Spanner Graph also integrates with the platform’s full-text search and vector search capabilities, which opens the door to features like semantic search over player-generated content (clan names, custom match titles) without a third external service.
This is worth evaluating during the free trial, since the trial instance supports these Enterprise features even though it defaults to the Standard tier’s pricing of zero during the trial window. Build the friend-ranking query against Spanner Graph before deciding whether the Enterprise price premium is worth it for your specific feature roadmap, rather than assuming you need it from day one.
Advanced Tip: Use Interleaved Secondary Indexes for Regional Leaderboards
Beyond the season-wide leaderboard built in this tutorial, most live-service games also want a “leaderboard in your region” view. Rather than filtering the global index client-side, add a second interleaved index that includes region:
CREATE INDEX LeaderboardByRegionAndScore
ON LeaderboardEntries (SeasonId, Score DESC)
STORING (PlayerId, MatchCount)
WHERE Score > 0;
A partial index condition like WHERE Score > 0 keeps zero-score placeholder rows (created the moment a player registers but before their first match) out of the index entirely, which keeps the index smaller and queries against it faster as your player base grows into the millions.
The Complete Working Project
Putting every piece from this tutorial together, a minimal production-ready project has this structure:
game-leaderboard-spanner/
âââ schema.sql # Step 5 â table and index definitions
âââ leaderboard.js # Step 6 â Node.js read/write logic
âââ leaderboard.py # Step 7 â Python equivalent
âââ seed.js # Step 8 â synthetic load test data
âââ server.js # Step 12 â REST API wrapper
âââ leaderboard-key.json # Step 10 â local-only service account key (gitignore this)
âââ package.json
Deploy server.js behind Cloud Run or GKE, point your game client at its REST endpoints, and you have a global leaderboard backend that won’t silently lose a write when two players in different hemispheres submit scores within the same millisecond. That correctness guarantee, not raw throughput, is the actual reason to reach for Spanner over a simpler key-value store for this specific piece of game infrastructure.
Frequently Asked Questions
Is Cloud Spanner overkill for a small indie game?
For most indie titles with a single-region player base and no real-money tournament stakes, yes â a regional Cloud SQL instance or even Firestore will be cheaper and simpler. Spanner earns its cost when you have genuinely global players and a correctness requirement (competitive ranking, in-game economy, matchmaking rating) where eventual consistency causes real problems.
How much does a production Spanner leaderboard cost per month?
A single node (1,000 processing units) on Standard edition runs about $0.90/hour, or roughly $650/month running continuously, before storage and network costs. A multi-region config at the same processing unit count costs the same per-PU rate, but you’ll typically need more processing units to hit equivalent throughput because of the cross-region write overhead â budget accordingly rather than assuming a direct one-to-one swap from regional to multi-region.
Can I use Spanner’s PostgreSQL dialect instead of GoogleSQL?
Yes. Both the free trial instance and paid instances support a PostgreSQL-dialect database alongside GoogleSQL. The schema and queries in this tutorial use GoogleSQL syntax; the PostgreSQL dialect uses standard SQL types and syntax instead, with equivalent interleaving support through Spanner-specific extensions.
Does Spanner support automatic failover if a region goes down?
Yes, that’s built into multi-region configurations. Spanner maintains a quorum of replicas across the regions in your chosen config, and Enterprise Plus specifically advertises up to 99.999% availability as the top SLA tier for workloads that need to survive a full regional outage without manual intervention.
What happens to leaderboard data when a season ends?
In the schema from Step 5, deleting a row from the Seasons table cascades to delete all of its interleaved LeaderboardEntries automatically, because the table was created with INTERLEAVE IN PARENT Seasons ON DELETE CASCADE. If you want to archive historical season results instead of deleting them, copy the rows into a separate ArchivedSeasons table before running the delete, since the cascade is irreversible once committed.
Is Spanner a good fit for matchmaking rating (MMR) systems, not just leaderboards?
It can be, for the same consistency reasons covered throughout this tutorial â an MMR update after a match is a read-modify-write operation very similar to the score-update logic built in Steps 6 and 7. The caveat is that MMR systems often need very low read latency for live matchmaking decisions, so many studios keep MMR in a faster in-memory store like Redis (our Upstash vs. Redis Cloud vs. ElastiCache comparison covers picking one) for the hot matchmaking path, pairing it with a system like Google’s Open Match 2 on GKE for the matching logic itself, and use Spanner as the durable system of record that gets reconciled after each match completes.
How do I migrate existing DynamoDB leaderboard data into Spanner?
There’s no managed one-click migration tool between the two. The practical path is exporting DynamoDB data to a flat format (JSON or CSV) through a scan or Data Pipeline export, transforming it into rows matching your new Spanner schema, and bulk-loading it with Spanner’s batch write API rather than looping individual inserts, since batch writes are substantially more efficient for a one-time backfill of this size.





