Claude Skill

api-database-cockroachdb

CockroachDB distributed SQL -- transaction retries, multi-region, online schema changes, follower reads, PostgreSQL compatibility gaps

LLM Mart · 0 points · 0 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download agents-inc-skills-dist_plugins_api-database-cockroachdb_skills_api-database-cockroachdb-3a51ef5.zip · 24 KB
Part of agents-inc/skills — 130 skills

Install

skills CLI npx skills add https://github.com/agents-inc/skills/tree/main/dist/plugins/api-database-cockroachdb/skills/api-database-cockroachdb
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install agents-inc-skills@llmmart
Git git clone https://github.com/agents-inc/skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole agents-inc/skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

CockroachDB Patterns

Quick Guide: CockroachDB connects via the standard pg driver (PostgreSQL wire protocol). The single most important difference from PostgreSQL: transaction retries are mandatory. CockroachDB's serializable isolation means any transaction can fail with SQLSTATE 40001 -- your application MUST catch this and retry the entire transaction. Use UUID with gen_random_uuid() for primary keys (never SERIAL -- sequential IDs cause distributed hotspots). DDL runs as online schema changes in background jobs and cannot be inside explicit transactions. Use AS OF SYSTEM TIME for follower reads to reduce latency in multi-region deployments.


<critical_requirements>

CRITICAL: Before Using This Skill

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST implement transaction retry logic for SQLSTATE 40001 errors -- CockroachDB WILL return serialization errors under normal operation, unlike PostgreSQL where they are rare)

(You MUST use UUID with gen_random_uuid() for primary keys -- NEVER use SERIAL or sequential IDs, which cause distributed write hotspots)

(You MUST NOT put DDL statements inside explicit transactions -- most DDL runs as background jobs and can fail at COMMIT time with a partially applied state. CREATE TABLE/CREATE INDEX are exceptions but the safest practice is always: one DDL statement per implicit transaction)

(You MUST use Pool from pg for all database access -- same as PostgreSQL, but be aware that each node in the cluster is a valid connection target)

</critical_requirements>


Examples

Additional resources:

  • reference.md -- PostgreSQL compatibility gaps, error codes, type differences, production checklist

Auto-detection: CockroachDB, cockroachdb, cockroach, CRDB, crdb, cockroach_restart, SAVEPOINT cockroach_restart, 40001, serialization_failure, retry transaction, restart transaction, gen_random_uuid, unique_rowid, AS OF SYSTEM TIME, follower_read_timestamp, CHANGEFEED, CREATE CHANGEFEED, IMPORT INTO, cockroach sql, cockroach start, multi-region, survival goal, zone survival, region survival, locality, REGIONAL BY ROW

When to use:

  • Direct SQL queries against CockroachDB via the pg driver
  • Distributed transactions requiring serializable isolation
  • Multi-region database deployments with locality-aware reads/writes
  • Applications migrating from PostgreSQL to CockroachDB
  • Change data capture with CHANGEFEED
  • Bulk data loading with IMPORT INTO

Key patterns covered:

  • Transaction retry logic (SQLSTATE 40001 handling with exponential backoff)
  • UUID primary keys with gen_random_uuid() (hotspot avoidance)
  • AS OF SYSTEM TIME for follower reads and historical queries
  • Multi-region configuration (locality, survival goals, regional tables)
  • Online schema changes (DDL behavior differences from PostgreSQL)
  • PostgreSQL compatibility gaps (what does NOT work)

When NOT to use:

  • You need an ORM or query builder -- use your ORM/query builder skill instead
  • You are targeting standard PostgreSQL without CockroachDB -- use the PostgreSQL skill
  • You need features CockroachDB lacks (advisory locks, full stored procedure support, CREATE DOMAIN)



<decision_framework>

Decision Framework

Primary Key Strategy

What type of primary key?
+-- Need human-readable IDs? -> UUID with gen_random_uuid() + separate readable slug column
+-- Need globally unique IDs? -> UUID with gen_random_uuid() (recommended default)
+-- Migrating from PostgreSQL SERIAL? -> Switch to UUID, backfill existing data
+-- Need monotonically increasing? -> DO NOT -- use UUID. If you absolutely must, use
|                                      SERIAL but understand the hotspot tradeoff.

Isolation Level Choice

Which isolation level?
+-- Need strongest guarantees? -> SERIALIZABLE (default, recommended)
|   +-- Your app handles 40001 retries? -> Yes, use SERIALIZABLE
|   +-- Cannot implement retry logic? -> Consider READ COMMITTED
+-- Analytics / read-heavy workload? -> READ COMMITTED (no retry needed)
+-- Background jobs with loose consistency? -> READ COMMITTED

Read Strategy

How fresh must the data be?
+-- Must see latest writes? -> Normal read (hits leaseholder)
+-- Stale by a few seconds is fine? -> AS OF SYSTEM TIME follower_read_timestamp()
+-- Need a specific historical snapshot? -> AS OF SYSTEM TIME '<timestamp>'
+-- Exporting data for analytics? -> AS OF SYSTEM TIME with follower reads

Schema Change Strategy

How to run DDL?
+-- Single column add/drop? -> Run as individual statement (no transaction)
+-- Multiple related changes? -> Run sequentially, one statement at a time
+-- Need to roll back DDL? -> You cannot -- DDL is not transactional. Plan carefully.
+-- Index creation on large table? -> All indexes are created online by default (do NOT use CONCURRENTLY -- it errors)

</decision_framework>


<red_flags>

RED FLAGS

High Priority Issues:

  • No transaction retry logic for 40001 errors -- CockroachDB WILL return these under normal concurrent load. Without retries, your application randomly fails under traffic.
  • Using SERIAL or sequential primary keys -- creates a write hotspot on a single range, bottlenecking the entire cluster on one node.
  • DDL inside explicit transactions -- most DDL can fail at COMMIT time with a partially applied state. CREATE TABLE/CREATE INDEX are exceptions, but the safest practice is one DDL per implicit transaction.
  • Using advisory locks (pg_advisory_lock, pg_try_advisory_lock) -- CockroachDB does NOT implement them. They are defined as no-op stubs that silently do nothing.

Medium Priority Issues:

  • Not using AS OF SYSTEM TIME for read-heavy workloads in multi-region -- forces all reads to hit the leaseholder, adding cross-region latency.
  • Running multiple DDL statements simultaneously in production -- each schema change consumes resources. Run them sequentially.
  • Assuming PostgreSQL LISTEN/NOTIFY works -- CockroachDB does NOT support LISTEN/NOTIFY. Use CHANGEFEED for real-time change streaming.
  • Using CREATE DOMAIN -- not supported in CockroachDB. Use CHECK constraints or application-level validation.

Common Mistakes:

  • Connecting to port 5432 instead of 26257 -- CockroachDB default port is 26257.
  • Expecting SERIAL to produce gapless sequential IDs -- CockroachDB's unique_rowid() produces time-ordered but non-sequential values with gaps.
  • Forgetting that numeric/decimal types return as strings in the pg driver (same behavior as PostgreSQL).
  • Wrapping retry logic around individual statements instead of the entire transaction -- you must retry the FULL transaction, not just the failed statement.
  • Using SELECT ... FOR UPDATE without understanding it acquires locks across the cluster -- it works but has higher latency than in PostgreSQL.

Gotchas & Edge Cases:

  • 40001 errors can occur on COMMIT, not just on individual statements. Your retry loop must catch errors from COMMIT too.
  • CockroachDB's SAVEPOINT cockroach_restart is a special savepoint name that enables the advanced retry protocol. Regular savepoints (SAVEPOINT my_savepoint) work normally for nested rollback.
  • Temporary tables exist but are experimental (SET experimental_enable_temp_tables = 'on'). Creating many temp objects degrades DDL performance.
  • READ COMMITTED isolation is GA and enabled by default (sql.txn.read_committed_isolation.enabled = true), but transactions still default to SERIALIZABLE. Set per-transaction with BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED, per-session with SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED, or per-database with ALTER DATABASE db SET default_transaction_isolation = 'read committed'.
  • CockroachDB's pg_catalog and information_schema are populated but may have differences from PostgreSQL -- some system tables have extra columns, some are missing columns.
  • IMPORT INTO takes the target table offline during the import. The table cannot serve reads or writes until the import completes.
  • Changefeed payload is limited. Complex JOINs or aggregations cannot be expressed directly in changefeed queries -- one table per changefeed.
  • Float overflow returns Infinity in CockroachDB (PostgreSQL returns an error).
  • Bitwise operator precedence differs from PostgreSQL. Use explicit parentheses.

</red_flags>


<critical_reminders>

CRITICAL REMINDERS

All code must follow project conventions in CLAUDE.md (kebab-case, named exports, import ordering, import type, named constants)

(You MUST implement transaction retry logic for SQLSTATE 40001 errors -- CockroachDB WILL return serialization errors under normal operation, unlike PostgreSQL where they are rare)

(You MUST use UUID with gen_random_uuid() for primary keys -- NEVER use SERIAL or sequential IDs, which cause distributed write hotspots)

(You MUST NOT put DDL statements inside explicit transactions -- most DDL runs as background jobs and can fail at COMMIT time with a partially applied state. CREATE TABLE/CREATE INDEX are exceptions but the safest practice is always: one DDL statement per implicit transaction)

(You MUST use Pool from pg for all database access -- same as PostgreSQL, but be aware that each node in the cluster is a valid connection target)

Failure to follow these rules will cause transaction failures under load, write hotspots that defeat distribution, DDL errors, and application crashes.

</critical_reminders>

Files (skills)
  • examples
    • core.md 13.4 KB
      # CockroachDB -- Core Pattern Examples
      
      > Pool setup, transaction retry logic, UUID primary keys, parameterized queries, and error handling. Reference from [SKILL.md](../SKILL.md).
      
      **Related examples:**
      
      - [multi-region.md](multi-region.md) -- Locality, survival goals, follower reads, AS OF SYSTEM TIME
      - [schema-ops.md](schema-ops.md) -- Online schema changes, IMPORT INTO, CHANGEFEED
      
      ---
      
      ## Pool Setup
      
      CockroachDB uses the standard `pg` driver. Configuration is identical to PostgreSQL except for the default port (26257) and SSL requirements.
      
      ```typescript
      import pg from "pg";
      
      const POOL_MAX_CLIENTS = 20;
      const IDLE_TIMEOUT_MS = 30_000;
      const CONNECTION_TIMEOUT_MS = 5_000;
      const MAX_LIFETIME_SECONDS = 1_800;
      
      function createPool(): pg.Pool {
        const connectionString = process.env.DATABASE_URL;
        if (!connectionString) {
          throw new Error("DATABASE_URL environment variable is required");
        }
      
        const pool = new pg.Pool({
          connectionString,
          // Example: postgresql://user:pass@crdb-lb:26257/mydb?sslmode=verify-full
          max: POOL_MAX_CLIENTS,
          idleTimeoutMillis: IDLE_TIMEOUT_MS,
          connectionTimeoutMillis: CONNECTION_TIMEOUT_MS,
          maxLifetimeSeconds: MAX_LIFETIME_SECONDS,
        });
      
        // REQUIRED: idle client errors crash the process if unhandled
        pool.on("error", (err) => {
          console.error("Unexpected idle client error:", err.message);
        });
      
        return pool;
      }
      
      export { createPool };
      ```
      
      **Why good:** Standard pg Pool, environment variable validation, named constants, error handler prevents process crash, `connectionTimeoutMillis` prevents infinite waits on pool exhaustion
      
      ```typescript
      // Bad Example - Wrong port, no SSL
      import pg from "pg";
      
      const pool = new pg.Pool({
        host: "crdb-node-1",
        port: 5432, // WRONG -- CockroachDB default is 26257
        database: "mydb",
        user: "root",
        // No SSL -- CockroachDB Cloud requires it, self-hosted strongly recommends it
      });
      // No pool.on("error") handler
      ```
      
      **Why bad:** Wrong port (5432 is PostgreSQL default, CockroachDB is 26257), no SSL for a distributed database, hardcoded credentials, no error handler
      
      ---
      
      ## Transaction Retry Helper (MANDATORY)
      
      This is the most important pattern for CockroachDB. Any transaction can fail with SQLSTATE `40001` due to serialization conflicts. Your application MUST retry.
      
      ```typescript
      import type pg from "pg";
      
      const CRDB_SERIALIZATION_FAILURE = "40001";
      const CRDB_STATEMENT_COMPLETION_UNKNOWN = "40003";
      const MAX_RETRIES = 5;
      const BASE_DELAY_MS = 50;
      
      const RETRYABLE_CODES = new Set([
        CRDB_SERIALIZATION_FAILURE,
        CRDB_STATEMENT_COMPLETION_UNKNOWN,
      ]);
      
      interface PgError extends Error {
        code: string;
        constraint?: string;
        detail?: string;
      }
      
      function isPgError(err: unknown): err is PgError {
        return err instanceof Error && "code" in err;
      }
      
      function isCrdbRetryError(err: unknown): boolean {
        if (!isPgError(err)) return false;
        if (RETRYABLE_CODES.has(err.code)) return true;
        return err.message.startsWith("restart transaction");
      }
      
      async function withCrdbRetry<T>(
        pool: pg.Pool,
        operation: (client: pg.PoolClient) => Promise<T>,
      ): Promise<T> {
        for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
          const client = await pool.connect();
          try {
            await client.query("BEGIN");
            const result = await operation(client);
            await client.query("COMMIT");
            return result;
          } catch (err) {
            await client.query("ROLLBACK");
      
            if (isCrdbRetryError(err) && attempt < MAX_RETRIES) {
              // Exponential backoff with jitter
              const delay =
                BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * BASE_DELAY_MS;
              await new Promise((resolve) => setTimeout(resolve, delay));
              continue;
            }
      
            throw err;
          } finally {
            client.release();
          }
        }
      
        throw new Error("CockroachDB retry loop exited unexpectedly");
      }
      
      export {
        withCrdbRetry,
        isCrdbRetryError,
        isPgError,
        CRDB_SERIALIZATION_FAILURE,
        CRDB_STATEMENT_COMPLETION_UNKNOWN,
      };
      ```
      
      **Why good:** Handles both 40001 and 40003, checks message prefix for CockroachDB-specific retry signals, exponential backoff with jitter, fresh client per attempt (avoids tainted connection state), bounded retries
      
      **Gotcha:** Errors can occur on `COMMIT`, not just on individual statements. The retry loop above correctly catches errors from any point in the transaction including the COMMIT.
      
      **Gotcha:** The operation callback must NOT have side effects outside the database (e.g., sending emails, making HTTP calls). If the transaction retries, the callback runs again. Keep side effects AFTER the `withCrdbRetry` call returns.
      
      ---
      
      ## Using the Retry Helper
      
      ```typescript
      import type pg from "pg";
      
      interface TransferResult {
        fromBalance: string; // numeric returns as string
        toBalance: string;
      }
      
      async function transferFunds(
        pool: pg.Pool,
        fromAccountId: string, // UUID
        toAccountId: string,
        amount: string, // numeric as string for precision
      ): Promise<TransferResult> {
        return withCrdbRetry(pool, async (client) => {
          // Lock rows in consistent order to reduce contention
          const { rows } = await client.query<{ id: string; balance: string }>(
            `SELECT id, balance FROM accounts
             WHERE id = ANY($1)
             ORDER BY id FOR UPDATE`,
            [[fromAccountId, toAccountId]],
          );
      
          const fromAccount = rows.find((r) => r.id === fromAccountId);
          const toAccount = rows.find((r) => r.id === toAccountId);
      
          if (!fromAccount || !toAccount) {
            throw new Error("Account not found");
          }
      
          if (parseFloat(fromAccount.balance) < parseFloat(amount)) {
            throw new Error("Insufficient balance");
          }
      
          await client.query(
            "UPDATE accounts SET balance = balance - $1 WHERE id = $2",
            [amount, fromAccountId],
          );
          await client.query(
            "UPDATE accounts SET balance = balance + $1 WHERE id = $2",
            [amount, toAccountId],
          );
      
          return {
            fromBalance: (
              parseFloat(fromAccount.balance) - parseFloat(amount)
            ).toString(),
            toBalance: (
              parseFloat(toAccount.balance) + parseFloat(amount)
            ).toString(),
          };
        });
      }
      
      export { transferFunds };
      ```
      
      **Why good:** Wrapped in `withCrdbRetry` for automatic 40001 handling, `FOR UPDATE` with `ORDER BY id` reduces contention and prevents deadlocks, UUID primary keys, numeric handled as strings
      
      ---
      
      ## Advanced Retry: SAVEPOINT cockroach_restart
      
      CockroachDB supports a special savepoint name `cockroach_restart` that enables an advanced retry protocol. This is useful when you want CockroachDB to automatically handle retries for transactions within the server's internal buffer size.
      
      ```typescript
      import type pg from "pg";
      
      const MAX_RETRIES = 5;
      const BASE_DELAY_MS = 50;
      
      async function withSavepointRetry<T>(
        pool: pg.Pool,
        operation: (client: pg.PoolClient) => Promise<T>,
      ): Promise<T> {
        const client = await pool.connect();
        try {
          await client.query("BEGIN");
          for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
            await client.query("SAVEPOINT cockroach_restart");
            try {
              const result = await operation(client);
              await client.query("RELEASE SAVEPOINT cockroach_restart");
              await client.query("COMMIT");
              return result;
            } catch (err) {
              if (isCrdbRetryError(err) && attempt < MAX_RETRIES) {
                await client.query("ROLLBACK TO SAVEPOINT cockroach_restart");
                const delay =
                  BASE_DELAY_MS * Math.pow(2, attempt) +
                  Math.random() * BASE_DELAY_MS;
                await new Promise((resolve) => setTimeout(resolve, delay));
                continue;
              }
              throw err;
            }
          }
          throw new Error("SAVEPOINT retry loop exited unexpectedly");
        } catch (err) {
          await client.query("ROLLBACK");
          throw err;
        } finally {
          client.release();
        }
      }
      
      export { withSavepointRetry };
      ```
      
      **Why good:** Uses CockroachDB's native retry protocol, single client across retries (avoids pool checkout overhead), `ROLLBACK TO SAVEPOINT` resets transaction state cleanly
      
      **When to use:** High-contention workloads where you want to avoid the overhead of checking out a new client per retry. The standard `withCrdbRetry` (fresh client per attempt) is simpler and recommended for most cases.
      
      ---
      
      ## Parameterized Queries with UUID
      
      ```typescript
      import type pg from "pg";
      
      interface UserRow {
        id: string; // UUID
        email: string;
        name: string;
        created_at: Date;
      }
      
      // SELECT with UUID parameter
      async function getUserById(
        pool: pg.Pool,
        userId: string,
      ): Promise<UserRow | undefined> {
        const result = await pool.query<UserRow>(
          "SELECT id, email, name, created_at FROM users WHERE id = $1",
          [userId],
        );
        return result.rows[0];
      }
      
      // INSERT with gen_random_uuid() (database generates the UUID)
      async function createUser(
        pool: pg.Pool,
        email: string,
        name: string,
      ): Promise<UserRow> {
        const result = await pool.query<UserRow>(
          `INSERT INTO users (email, name)
           VALUES ($1, $2)
           RETURNING id, email, name, created_at`,
          [email, name],
        );
        return result.rows[0];
      }
      
      // Array parameter -- use ANY($1), not IN ($1)
      async function getUsersByIds(
        pool: pg.Pool,
        userIds: string[],
      ): Promise<UserRow[]> {
        const result = await pool.query<UserRow>(
          "SELECT id, email, name, created_at FROM users WHERE id = ANY($1)",
          [userIds],
        );
        return result.rows;
      }
      
      export { getUserById, createUser, getUsersByIds };
      ```
      
      **Why good:** UUID primary keys (string type in TypeScript), parameterized queries, `RETURNING` avoids a second query, `= ANY($1)` for array parameters (not `IN ($1)`)
      
      ---
      
      ## Error Handling with CockroachDB-Specific Codes
      
      ```typescript
      import type pg from "pg";
      
      // PostgreSQL SQLSTATE codes (also used by CockroachDB)
      const PG_UNIQUE_VIOLATION = "23505";
      const PG_FOREIGN_KEY_VIOLATION = "23503";
      const PG_NOT_NULL_VIOLATION = "23502";
      const PG_CHECK_VIOLATION = "23514";
      
      // CockroachDB-specific retry codes
      const CRDB_SERIALIZATION_FAILURE = "40001";
      const CRDB_STATEMENT_COMPLETION_UNKNOWN = "40003";
      
      interface PgError extends Error {
        code: string;
        constraint?: string;
        detail?: string;
        table?: string;
        column?: string;
      }
      
      function isPgError(err: unknown): err is PgError {
        return err instanceof Error && "code" in err;
      }
      
      async function createUser(
        pool: pg.Pool,
        email: string,
        name: string,
      ): Promise<UserRow> {
        // NOTE: this should be called INSIDE withCrdbRetry for 40001 handling
        try {
          const result = await pool.query<UserRow>(
            "INSERT INTO users (email, name) VALUES ($1, $2) RETURNING *",
            [email, name],
          );
          return result.rows[0];
        } catch (err) {
          if (!isPgError(err)) throw err;
      
          switch (err.code) {
            case PG_UNIQUE_VIOLATION:
              throw new ConflictError(
                `Duplicate value for constraint: ${err.constraint}`,
              );
            case PG_FOREIGN_KEY_VIOLATION:
              throw new NotFoundError(
                `Referenced entity does not exist: ${err.detail}`,
              );
            case PG_NOT_NULL_VIOLATION:
              throw new ValidationError(`Missing required field: ${err.column}`);
            case PG_CHECK_VIOLATION:
              throw new ValidationError(`Validation failed: ${err.constraint}`);
            default:
              throw err;
          }
        }
      }
      
      export {
        PG_UNIQUE_VIOLATION,
        PG_FOREIGN_KEY_VIOLATION,
        PG_NOT_NULL_VIOLATION,
        PG_CHECK_VIOLATION,
        CRDB_SERIALIZATION_FAILURE,
        CRDB_STATEMENT_COMPLETION_UNKNOWN,
      };
      ```
      
      **Why good:** Named constants for all error codes, type guard for safe property access, CockroachDB uses the same PostgreSQL SQLSTATE codes for constraint violations
      
      **Gotcha:** `40001` errors should be handled at the transaction level (by `withCrdbRetry`), not inside individual query functions. Constraint violations (23xxx) are application errors that should NOT be retried.
      
      ---
      
      ## Graceful Pool Shutdown
      
      ```typescript
      import type pg from "pg";
      
      async function gracefulShutdown(pool: pg.Pool): Promise<void> {
        console.log("Shutting down database pool...");
        await pool.end();
        console.log("Database pool closed");
      }
      
      process.on("SIGTERM", async () => {
        await gracefulShutdown(pool);
        process.exit(0);
      });
      
      process.on("SIGINT", async () => {
        await gracefulShutdown(pool);
        process.exit(0);
      });
      ```
      
      **Why good:** Same as PostgreSQL -- `pool.end()` drains the pool cleanly
      
      ---
      
      ## Table Schema Design
      
      ```sql
      -- Good Example - CockroachDB-optimized table
      CREATE TABLE orders (
        id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
        user_id UUID NOT NULL REFERENCES users(id),
        total DECIMAL(10, 2) NOT NULL,
        status TEXT NOT NULL DEFAULT 'pending',
        created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
        updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
      
        INDEX idx_orders_user_id (user_id),
        INDEX idx_orders_status (status) WHERE status != 'completed'
      );
      ```
      
      **Why good:** UUID primary key distributes writes, foreign key to UUID column, partial index on status reduces index size, DECIMAL for money (returned as string by pg driver)
      
      ```sql
      -- Bad Example - PostgreSQL patterns that hurt CockroachDB
      CREATE TABLE orders (
        id SERIAL PRIMARY KEY, -- hotspot!
        user_id INTEGER NOT NULL REFERENCES users(id), -- assumes integer PK on users
        total MONEY NOT NULL, -- MONEY type has portability issues
        status TEXT NOT NULL DEFAULT 'pending',
        created_at TIMESTAMP NOT NULL DEFAULT now() -- use TIMESTAMPTZ, not TIMESTAMP
      );
      ```
      
      **Why bad:** SERIAL creates write hotspot, INTEGER FK assumes sequential PK, MONEY type has formatting issues across locales, TIMESTAMP without timezone loses timezone info
      
      ---
      
      _Full skill documentation: [SKILL.md](../SKILL.md) | Quick reference: [reference.md](../reference.md)_
      
    • multi-region.md 9.8 KB
      # CockroachDB -- Multi-Region & Performance Examples
      
      > Locality configuration, survival goals, follower reads, AS OF SYSTEM TIME, and performance optimization. Reference from [SKILL.md](../SKILL.md).
      
      **Related examples:**
      
      - [core.md](core.md) -- Pool setup, transaction retry logic, parameterized queries
      - [schema-ops.md](schema-ops.md) -- Online schema changes, IMPORT INTO, CHANGEFEED
      
      ---
      
      ## Multi-Region Database Setup
      
      Configure a database to span multiple regions with locality-aware data placement.
      
      ```sql
      -- Step 1: Start nodes with locality flags
      -- cockroach start --locality=region=us-east1,zone=us-east1-b ...
      -- cockroach start --locality=region=us-west1,zone=us-west1-a ...
      -- cockroach start --locality=region=eu-west1,zone=eu-west1-c ...
      
      -- Step 2: Set primary region
      ALTER DATABASE myapp PRIMARY REGION "us-east1";
      
      -- Step 3: Add secondary regions
      ALTER DATABASE myapp ADD REGION "us-west1";
      ALTER DATABASE myapp ADD REGION "eu-west1";
      
      -- Step 4: Set survival goal
      ALTER DATABASE myapp SURVIVE ZONE FAILURE;
      -- Or for maximum availability (requires 3+ regions):
      -- ALTER DATABASE myapp SURVIVE REGION FAILURE;
      ```
      
      **Why good:** Locality flags on nodes tell CockroachDB where each node is physically located, enabling intelligent data placement. Region failure survival requires 5 replicas (2+2+1 across 3 regions).
      
      ---
      
      ## Table Locality Configuration
      
      ### Regional By Table (Default)
      
      All data for the table resides in one region. Best for data accessed primarily from one location.
      
      ```sql
      -- Data lives in the primary region
      ALTER TABLE users SET LOCALITY REGIONAL BY TABLE IN PRIMARY REGION;
      
      -- Or pin to a specific region
      ALTER TABLE eu_compliance_logs SET LOCALITY REGIONAL BY TABLE IN "eu-west1";
      ```
      
      **When to use:** Tables accessed from a single region (e.g., region-specific audit logs, compliance data that must stay in a jurisdiction).
      
      ---
      
      ### Regional By Row
      
      Each row is placed in the region specified by its `crdb_region` column. Best for user data where users are geo-distributed.
      
      ```sql
      -- Step 1: Add the region column
      ALTER TABLE users ADD COLUMN crdb_region crdb_internal_region
        NOT NULL DEFAULT gateway_region()
        AS (
          CASE
            WHEN country IN ('US', 'CA', 'MX') THEN 'us-east1'
            WHEN country IN ('GB', 'DE', 'FR') THEN 'eu-west1'
            ELSE 'us-west1'
          END
        ) STORED;
      
      -- Step 2: Set the locality
      ALTER TABLE users SET LOCALITY REGIONAL BY ROW;
      ```
      
      **Why good:** Each user's data lives in their nearest region. Reads and writes for that user are local. The computed column automatically places rows based on the `country` field.
      
      **When to use:** User profiles, session data, per-tenant data -- anything where each record has a natural region affinity.
      
      ```typescript
      // Application code -- insert with explicit region
      await pool.query(
        `INSERT INTO users (email, name, country, crdb_region)
         VALUES ($1, $2, $3, $4)`,
        [email, name, country, "us-east1"],
      );
      
      // Or let the computed column handle it
      await pool.query(
        `INSERT INTO users (email, name, country)
         VALUES ($1, $2, $3)`,
        [email, name, country],
        // crdb_region is computed from country
      );
      ```
      
      ---
      
      ### Global Tables
      
      Data is replicated to all regions for fast reads everywhere. Writes are slower because they require cross-region consensus.
      
      ```sql
      ALTER TABLE feature_flags SET LOCALITY GLOBAL;
      ALTER TABLE exchange_rates SET LOCALITY GLOBAL;
      ALTER TABLE app_config SET LOCALITY GLOBAL;
      ```
      
      **When to use:** Reference data, configuration, feature flags -- tables that are read frequently from all regions but updated rarely.
      
      **Gotcha:** Global table writes require consensus across all regions, adding latency proportional to the cross-region round-trip time. Only use GLOBAL for tables with a high read-to-write ratio.
      
      ---
      
      ## AS OF SYSTEM TIME (Follower Reads)
      
      ### Basic Follower Read
      
      Read slightly stale data from the nearest replica. This avoids the round-trip to the leaseholder.
      
      ```typescript
      import type pg from "pg";
      
      interface ProductRow {
        id: string;
        name: string;
        price: string; // DECIMAL returns as string
        category: string;
      }
      
      // Follower read -- served by nearest replica
      async function getProducts(
        pool: pg.Pool,
        category: string,
      ): Promise<ProductRow[]> {
        const result = await pool.query<ProductRow>(
          `SELECT id, name, price, category
           FROM products
           WHERE category = $1
           AS OF SYSTEM TIME follower_read_timestamp()`,
          [category],
        );
        return result.rows;
      }
      
      export { getProducts };
      ```
      
      **Why good:** `follower_read_timestamp()` automatically computes a safe staleness window (at least 4.2 seconds in the past). The query is served by any replica, avoiding leaseholder round-trip.
      
      ---
      
      ### Explicit Staleness
      
      When you need a specific staleness guarantee or a historical snapshot.
      
      ```typescript
      // Read data as of 10 seconds ago
      const TEN_SECONDS_AGO = "-10s";
      
      const result = await pool.query<ProductRow>(
        `SELECT id, name, price
         FROM products
         WHERE category = $1
         AS OF SYSTEM TIME now() - $2::INTERVAL`,
        [category, TEN_SECONDS_AGO],
      );
      
      // Read at a specific timestamp
      const result2 = await pool.query<ProductRow>(
        `SELECT id, name, price
         FROM products
         AS OF SYSTEM TIME '2025-01-15 12:00:00+00:00'`,
      );
      ```
      
      **When to use:** Historical snapshots for reports, consistent reads across multiple queries (same timestamp), analytics on past data.
      
      ---
      
      ### Bounded Staleness Read
      
      Read the freshest data available on the nearest replica, with a maximum staleness guarantee.
      
      ```typescript
      const MAX_STALENESS = "10s";
      
      // Bounded staleness: single-row lookup by primary key
      const result = await pool.query<ProductRow>(
        `SELECT id, name, price
         FROM products
         WHERE id = $1
         AS OF SYSTEM TIME with_max_staleness($2::INTERVAL)`,
        [productId, MAX_STALENESS],
      );
      ```
      
      **Why good:** Tries to serve the freshest data locally. If the local replica's data is within the staleness bound, it answers immediately. If not, it fetches from the leaseholder.
      
      **When to use:** When you want low latency but also want reasonably fresh data (e.g., a product price that can be up to 10 seconds stale).
      
      **Gotcha:** `with_max_staleness()` and `with_min_timestamp()` have strict limitations: they must be used in a **single-statement implicit transaction**, must read from a **single row**, and must not require an index join. For multi-row follower reads, use `follower_read_timestamp()` instead.
      
      ---
      
      ### Follower Reads in Explicit Transactions
      
      ```typescript
      async function generateReport(pool: pg.Pool): Promise<ReportData> {
        const client = await pool.connect();
        try {
          // All reads in this transaction see the same snapshot
          await client.query("BEGIN AS OF SYSTEM TIME follower_read_timestamp()");
      
          const users = await client.query("SELECT count(*) FROM users");
          const orders = await client.query("SELECT sum(total) FROM orders");
          const products = await client.query("SELECT count(*) FROM products");
      
          await client.query("COMMIT");
      
          return {
            userCount: parseInt(users.rows[0].count, 10),
            orderTotal: orders.rows[0].sum,
            productCount: parseInt(products.rows[0].count, 10),
          };
        } catch (err) {
          await client.query("ROLLBACK");
          throw err;
        } finally {
          client.release();
        }
      }
      
      export { generateReport };
      ```
      
      **Why good:** All queries in the transaction see a consistent snapshot, served by nearest replica, no retry logic needed (read-only follower reads cannot conflict)
      
      **Gotcha:** `AS OF SYSTEM TIME` transactions are read-only. You cannot INSERT, UPDATE, or DELETE within them.
      
      ---
      
      ## SELECT FOR UPDATE (Pessimistic Locking)
      
      CockroachDB supports `SELECT ... FOR UPDATE` but it acquires locks across the cluster. Use it for high-contention scenarios.
      
      ```typescript
      async function claimJob(
        pool: pg.Pool,
        workerId: string,
      ): Promise<JobRow | null> {
        return withCrdbRetry(pool, async (client) => {
          // SKIP LOCKED avoids waiting on rows locked by other workers
          const { rows } = await client.query<JobRow>(
            `SELECT id, payload FROM job_queue
             WHERE status = 'pending'
             ORDER BY created_at
             LIMIT 1
             FOR UPDATE SKIP LOCKED`,
          );
      
          if (rows.length === 0) return null;
      
          await client.query(
            `UPDATE job_queue
             SET status = 'processing', worker_id = $1, started_at = now()
             WHERE id = $2`,
            [workerId, rows[0].id],
          );
      
          return rows[0];
        });
      }
      
      export { claimJob };
      ```
      
      **Why good:** `FOR UPDATE SKIP LOCKED` is ideal for job queues -- it skips rows already claimed by other workers instead of waiting. Wrapped in `withCrdbRetry` for 40001 handling.
      
      **Gotcha:** `SELECT ... FOR UPDATE` has higher latency in CockroachDB than PostgreSQL because the lock must be coordinated across nodes. For low-contention workloads, optimistic locking (version columns) is often faster.
      
      ---
      
      ## Optimistic Locking with Version Columns
      
      An alternative to `FOR UPDATE` that avoids distributed lock coordination.
      
      ```typescript
      async function updateProductPrice(
        pool: pg.Pool,
        productId: string,
        newPrice: string,
        expectedVersion: number,
      ): Promise<boolean> {
        return withCrdbRetry(pool, async (client) => {
          const result = await client.query(
            `UPDATE products
             SET price = $1, version = version + 1, updated_at = now()
             WHERE id = $2 AND version = $3`,
            [newPrice, productId, expectedVersion],
          );
      
          if ((result.rowCount ?? 0) === 0) {
            throw new ConflictError(
              "Product was modified by another transaction. Re-read and retry.",
            );
          }
      
          return true;
        });
      }
      
      export { updateProductPrice };
      ```
      
      **Why good:** No distributed lock overhead, version check detects concurrent modifications, conflict results in a clear error message
      
      **When to use:** Low-to-moderate contention scenarios. For high contention (many concurrent writes to the same row), use `FOR UPDATE`.
      
      ---
      
      _Full skill documentation: [SKILL.md](../SKILL.md) | Quick reference: [reference.md](../reference.md)_
      
    • schema-ops.md 12 KB
      # CockroachDB -- Schema & Operations Examples
      
      > Online schema changes, IMPORT INTO for bulk data, CHANGEFEED for CDC, and cockroach CLI usage. Reference from [SKILL.md](../SKILL.md).
      
      **Related examples:**
      
      - [core.md](core.md) -- Pool setup, transaction retry logic, parameterized queries
      - [multi-region.md](multi-region.md) -- Locality, survival goals, follower reads
      
      ---
      
      ## Online Schema Changes
      
      CockroachDB DDL runs as background jobs. Tables remain available for reads and writes throughout the change. DDL statements CANNOT be inside explicit transactions.
      
      ### Adding Columns
      
      ```typescript
      import type pg from "pg";
      
      // Good Example - DDL as individual statements
      async function addPhoneColumn(pool: pg.Pool): Promise<void> {
        // Each DDL runs as an implicit transaction (individual statement)
        await pool.query("ALTER TABLE users ADD COLUMN phone TEXT");
        // Table is fully available during this operation
      }
      
      // Add column with default (backfills existing rows in background)
      async function addStatusColumn(pool: pg.Pool): Promise<void> {
        await pool.query(
          "ALTER TABLE orders ADD COLUMN priority TEXT NOT NULL DEFAULT 'normal'",
        );
        // CockroachDB backfills the default value in a background job
      }
      
      export { addPhoneColumn, addStatusColumn };
      ```
      
      **Why good:** Individual DDL statements, no transaction wrapper, table stays available
      
      ```typescript
      // Bad Example - DDL inside explicit transaction
      const client = await pool.connect();
      try {
        await client.query("BEGIN");
        await client.query("ALTER TABLE users ADD COLUMN phone TEXT");
        await client.query("ALTER TABLE users ADD COLUMN address TEXT");
        await client.query("COMMIT");
        // Most DDL can fail at COMMIT time with a partially applied state
        // CREATE TABLE/CREATE INDEX are exceptions, but mixing DDL types is unsafe
      } finally {
        client.release();
      }
      ```
      
      **Why bad:** Most DDL in explicit transactions can fail at COMMIT time with a partially applied state. The safest practice is one DDL statement per implicit transaction.
      
      ---
      
      ### Creating Indexes
      
      ```sql
      -- Good Example - Index creation (runs as background job)
      CREATE INDEX idx_orders_user_status ON orders (user_id, status);
      
      -- Partial index (reduces index size)
      CREATE INDEX idx_orders_pending ON orders (created_at)
        WHERE status = 'pending';
      
      -- GIN index for JSONB columns
      CREATE INVERTED INDEX idx_users_metadata ON users (metadata);
      ```
      
      **Why good:** Index creation runs in the background without blocking reads or writes. Partial indexes reduce storage and improve write performance.
      
      **Gotcha:** CockroachDB supports both `CREATE INVERTED INDEX` (CockroachDB-specific) and `CREATE INDEX ... USING GIN` (PostgreSQL-compatible) for JSONB/array indexes. Both are equivalent.
      
      ---
      
      ### Monitoring Schema Change Progress
      
      ```sql
      -- Check running schema change jobs
      SHOW JOBS WHERE job_type = 'SCHEMA CHANGE';
      
      -- Detailed job status
      SELECT job_id, description, status, fraction_completed, error
      FROM [SHOW JOBS]
      WHERE job_type = 'SCHEMA CHANGE'
        AND status != 'succeeded';
      
      -- Cancel a running schema change
      CANCEL JOB <job_id>;
      ```
      
      **Gotcha:** In production, run one DDL statement at a time and monitor via `SHOW JOBS`. Multiple concurrent schema changes compete for resources and can slow each other down significantly.
      
      ---
      
      ## Migration Pattern
      
      Since DDL cannot be in transactions, migrations need a different approach than PostgreSQL.
      
      ```typescript
      import type pg from "pg";
      import { readFileSync, readdirSync } from "node:fs";
      import { join } from "node:path";
      
      const MIGRATIONS_TABLE = "schema_migrations";
      
      async function ensureMigrationsTable(pool: pg.Pool): Promise<void> {
        // This DDL is fine as an implicit transaction
        await pool.query(`
          CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (
            id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
            name TEXT NOT NULL UNIQUE,
            applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
          )
        `);
      }
      
      async function getAppliedMigrations(pool: pg.Pool): Promise<Set<string>> {
        const result = await pool.query<{ name: string }>(
          `SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY applied_at`,
        );
        return new Set(result.rows.map((r) => r.name));
      }
      
      async function runMigrations(
        pool: pg.Pool,
        migrationsDir: string,
      ): Promise<string[]> {
        await ensureMigrationsTable(pool);
        const applied = await getAppliedMigrations(pool);
      
        const files = readdirSync(migrationsDir)
          .filter((f) => f.endsWith(".sql"))
          .sort();
      
        const newMigrations: string[] = [];
      
        for (const file of files) {
          if (applied.has(file)) continue;
      
          const sql = readFileSync(join(migrationsDir, file), "utf-8");
      
          // Split file into individual statements
          // DDL and DML may need to run as separate statements
          const statements = sql
            .split(";")
            .map((s) => s.trim())
            .filter((s) => s.length > 0);
      
          for (const statement of statements) {
            await pool.query(statement);
          }
      
          // Record migration AFTER all statements succeed
          await pool.query(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES ($1)`, [
            file,
          ]);
      
          newMigrations.push(file);
        }
      
        return newMigrations;
      }
      
      export { runMigrations };
      ```
      
      **Why good:** UUID primary key on migrations table, each DDL statement runs individually (not in a transaction), migration recorded after success
      
      **Gotcha:** Unlike PostgreSQL, if a migration fails partway through, some statements may have been applied but the migration is not recorded. You may need to manually clean up. Design migrations to be idempotent where possible (e.g., `CREATE INDEX IF NOT EXISTS`).
      
      ---
      
      ## IMPORT INTO (Bulk Data Loading)
      
      `IMPORT INTO` is CockroachDB's high-performance bulk loading mechanism. It is significantly faster than individual INSERTs for large datasets.
      
      ```sql
      -- Import from CSV (cloud storage)
      IMPORT INTO users (id, email, name, created_at)
        CSV DATA (
          'gs://my-bucket/users-part1.csv',
          'gs://my-bucket/users-part2.csv'
        )
        WITH skip = '1', nullif = '';
      
      -- Import from S3
      IMPORT INTO orders (id, user_id, total, status)
        CSV DATA (
          's3://my-bucket/orders.csv?AWS_ACCESS_KEY_ID={key}&AWS_SECRET_ACCESS_KEY={secret}'
        );
      
      -- Import with custom delimiter (TSV)
      IMPORT INTO products (id, name, price)
        CSV DATA ('gs://my-bucket/products.tsv')
        WITH delimiter = e'\t';
      
      -- Run import as detached background job
      IMPORT INTO users (id, email, name)
        CSV DATA ('gs://my-bucket/users.csv')
        WITH detached;
      ```
      
      **Why good:** Parallel bulk loading from multiple files, supports cloud storage directly, `detached` option runs as background job
      
      **Gotcha:** `IMPORT INTO` takes the target table **offline** during the import. The table cannot serve reads or writes until the import completes. Plan accordingly -- import during maintenance windows or into staging tables.
      
      **Gotcha:** Column order in the CSV must match the column order specified in the `IMPORT INTO` statement.
      
      ---
      
      ## CHANGEFEED (Change Data Capture)
      
      Stream row-level changes from tables to external systems.
      
      ### Changefeed to Kafka
      
      ```sql
      -- Stream all changes from orders table to Kafka
      CREATE CHANGEFEED FOR TABLE orders
        INTO 'kafka://broker.internal:9092'
        WITH updated, resolved = '10s',
             format = 'json',
             topic_prefix = 'crdb_';
      -- Produces to topic: crdb_orders
      ```
      
      ### Changefeed to Webhook
      
      ```sql
      CREATE CHANGEFEED FOR TABLE orders
        INTO 'webhook-https://my-api.example.com/webhooks/orders'
        WITH updated, resolved = '30s';
      ```
      
      ### Changefeed to Cloud Storage
      
      ```sql
      CREATE CHANGEFEED FOR TABLE orders
        INTO 'gs://my-bucket/cdc/orders'
        WITH updated, resolved = '1m',
             format = 'json';
      ```
      
      ### Sinkless Changefeed (SQL Client)
      
      ```sql
      -- Streams to the SQL client indefinitely
      CREATE CHANGEFEED FOR TABLE orders WITH updated;
      
      -- With filtering (CDC query)
      CREATE CHANGEFEED WITH updated AS
        SELECT id, status, updated_at
        FROM orders
        WHERE status IN ('shipped', 'delivered');
      ```
      
      ### Managing Changefeeds
      
      ```sql
      -- List active changefeeds
      SELECT job_id, description, status
      FROM [SHOW JOBS]
      WHERE job_type = 'CHANGEFEED';
      
      -- Pause a changefeed
      PAUSE JOB <job_id>;
      
      -- Resume a paused changefeed
      RESUME JOB <job_id>;
      
      -- Cancel a changefeed
      CANCEL JOB <job_id>;
      ```
      
      **When to use:** Event-driven architectures, real-time data replication, audit logging, cache invalidation, feeding data to analytics pipelines.
      
      **Gotcha:** CDC queries support only a single table per changefeed. You cannot JOIN across tables in a changefeed query.
      
      **Gotcha:** Enterprise changefeeds (with sinks like Kafka, S3, webhooks) require a CockroachDB Enterprise license. Sinkless changefeeds are available in all editions.
      
      ---
      
      ## cockroach CLI Operations
      
      ### Starting a Local Cluster
      
      ```bash
      # Start a single-node cluster for development
      cockroach start-single-node --insecure --store=node1 --listen-addr=localhost:26257
      
      # Start a 3-node cluster
      cockroach start --insecure --store=node1 --listen-addr=localhost:26257 \
        --join=localhost:26257,localhost:26258,localhost:26259
      
      cockroach start --insecure --store=node2 --listen-addr=localhost:26258 \
        --join=localhost:26257,localhost:26258,localhost:26259
      
      cockroach start --insecure --store=node3 --listen-addr=localhost:26259 \
        --join=localhost:26257,localhost:26258,localhost:26259
      
      # Initialize the cluster (run once after first start)
      cockroach init --insecure --host=localhost:26257
      ```
      
      ### SQL Shell
      
      ```bash
      # Connect to local insecure cluster
      cockroach sql --insecure --host=localhost:26257
      
      # Connect with SSL
      cockroach sql --url "postgresql://user@crdb-host:26257/mydb?sslmode=verify-full&sslrootcert=ca.crt"
      
      # Execute a single command
      cockroach sql --insecure -e "SELECT count(*) FROM users"
      ```
      
      ### Cluster Operations
      
      ```bash
      # Check node status
      cockroach node status --insecure --host=localhost:26257
      
      # Decommission a node (graceful removal)
      cockroach node decommission 3 --insecure --host=localhost:26257
      
      # Check running jobs (schema changes, imports, etc.)
      cockroach sql --insecure -e "SHOW JOBS WHERE status = 'running'"
      
      # Collect debug info for support
      cockroach debug zip debug.zip --insecure --host=localhost:26257
      ```
      
      ### Quick Demo Cluster
      
      ```bash
      # Spin up a temporary in-memory cluster with sample data
      cockroach demo
      
      # Demo with specific dataset
      cockroach demo --nodes=5 --demo-locality=region=us-east1:region=us-west1:region=eu-west1
      ```
      
      ---
      
      ## Testing with CockroachDB
      
      ### Docker-Based Test Cluster
      
      ```bash
      # Start CockroachDB for testing
      docker run -d --name crdb-test \
        -p 26257:26257 -p 8080:8080 \
        cockroachdb/cockroach:latest start-single-node --insecure
      
      # Create test database
      docker exec crdb-test cockroach sql --insecure \
        -e "CREATE DATABASE testdb"
      ```
      
      ### Test Pool Configuration
      
      ```typescript
      import pg from "pg";
      
      const TEST_POOL_MAX = 5;
      const TEST_IDLE_TIMEOUT_MS = 1_000;
      const TEST_CONNECTION_TIMEOUT_MS = 3_000;
      
      function createTestPool(): pg.Pool {
        const pool = new pg.Pool({
          connectionString:
            process.env.TEST_DATABASE_URL ??
            "postgresql://root@localhost:26257/testdb?sslmode=disable",
          max: TEST_POOL_MAX,
          idleTimeoutMillis: TEST_IDLE_TIMEOUT_MS,
          connectionTimeoutMillis: TEST_CONNECTION_TIMEOUT_MS,
          allowExitOnIdle: true,
        });
      
        pool.on("error", (err) => {
          console.error("Test pool error:", err.message);
        });
      
        return pool;
      }
      
      export { createTestPool };
      ```
      
      ### Transaction Rollback Test Isolation
      
      ```typescript
      import type pg from "pg";
      
      async function withTestTransaction<T>(
        pool: pg.Pool,
        testFn: (client: pg.PoolClient) => Promise<T>,
      ): Promise<T> {
        const client = await pool.connect();
        try {
          await client.query("BEGIN");
          const result = await testFn(client);
          return result;
        } finally {
          await client.query("ROLLBACK");
          client.release();
        }
      }
      
      export { withTestTransaction };
      ```
      
      **Why good:** Each test runs in a transaction that rolls back, leaving the database clean. Same pattern as PostgreSQL.
      
      **Gotcha:** Code under test must accept a `PoolClient` (not a `Pool`) so the test can inject the transactional client. If code uses `pool.query()`, it gets a different connection that cannot see the test transaction's uncommitted data.
      
      ---
      
      _Full skill documentation: [SKILL.md](../SKILL.md) | Quick reference: [reference.md](../reference.md)_
      
  • reference.md 14.3 KB
    # CockroachDB Quick Reference
    
    > PostgreSQL compatibility gaps, CockroachDB-specific error codes, type differences, and production checklist. See [SKILL.md](SKILL.md) for core concepts and [examples/](examples/) for code examples.
    
    ---
    
    ## PostgreSQL Compatibility Gaps
    
    ### Unsupported Features
    
    | Feature                                    | PostgreSQL                                     | CockroachDB                                                | Workaround                                                             |
    | ------------------------------------------ | ---------------------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------- |
    | Advisory locks                             | `pg_advisory_lock()`, `pg_try_advisory_lock()` | No-op stubs (silently do nothing)                          | Use `SELECT ... FOR UPDATE` on a lock table                            |
    | `LISTEN` / `NOTIFY`                        | Fully supported                                | Not supported                                              | Use `CHANGEFEED` for CDC                                               |
    | `CREATE DOMAIN`                            | Fully supported                                | Not supported                                              | Use `CHECK` constraints                                                |
    | Range types                                | `int4range`, `tsrange`, etc.                   | Not supported                                              | Use two columns (lower/upper bound)                                    |
    | XML functions                              | `xmlparse()`, `xpath()`, etc.                  | Not supported                                              | Handle XML in application code                                         |
    | Foreign data wrappers                      | `CREATE FOREIGN TABLE`                         | Not supported                                              | Use application-level data federation                                  |
    | Full text search (`tsvector`)              | Native GIN indexes                             | Limited support                                            | Use an external search engine                                          |
    | Column-level privileges                    | `GRANT SELECT(col)`                            | Not supported                                              | Use views to restrict column access                                    |
    | `CREATE TABLE ... PARTITION BY` (PG-style) | Fully supported                                | Different syntax -- uses CockroachDB-specific partitioning |
    | Table inheritance                          | `INHERITS` clause                              | Not supported                                              | Use separate tables with shared schema                                 |
    | Deferrable constraints                     | `DEFERRABLE INITIALLY DEFERRED`                | Not supported                                              | Validate constraints in application code or order operations carefully |
    | `%TYPE` / `%ROWTYPE` in PL/pgSQL           | Fully supported                                | Not supported                                              | Declare types explicitly                                               |
    | `UPDATE OF` column-list triggers           | Fully supported                                | Not supported                                              | Use full `UPDATE` triggers with condition checks                       |
    | `DROP TRIGGER ... CASCADE`                 | Fully supported                                | Not supported                                              | Drop dependent objects manually                                        |
    | Multiple arbiter indexes in `ON CONFLICT`  | Supported                                      | Not supported                                              | Use a single unique constraint per `ON CONFLICT`                       |
    
    ### Behavioral Differences
    
    | Behavior                    | PostgreSQL                     | CockroachDB                                                                |
    | --------------------------- | ------------------------------ | -------------------------------------------------------------------------- |
    | Default isolation level     | READ COMMITTED                 | SERIALIZABLE                                                               |
    | Float overflow              | Returns error                  | Returns `Infinity`                                                         |
    | Bitwise operator precedence | Standard SQL                   | Differs -- use explicit parentheses                                        |
    | `SERIAL` implementation     | Backed by sequence             | Backed by `unique_rowid()` (time-ordered, not sequential)                  |
    | DDL in transactions         | Fully supported                | Limited -- most DDL can fail at COMMIT; `CREATE TABLE`/`CREATE INDEX` work |
    | Schema changes              | Locks table briefly            | Online -- table remains available                                          |
    | Default port                | 5432                           | 26257                                                                      |
    | `numeric`/`decimal` returns | String (in pg driver)          | String (same as PostgreSQL)                                                |
    | `bigint` returns            | String when > MAX_SAFE_INTEGER | String (same as PostgreSQL)                                                |
    | Temporary tables            | Native                         | Experimental (`SET experimental_enable_temp_tables = 'on'`)                |
    | Stored procedures           | Full PL/pgSQL                  | Limited PL/pgSQL support                                                   |
    | `pg_catalog`                | Complete                       | Populated but may differ from PostgreSQL                                   |
    
    ---
    
    ## CockroachDB-Specific Error Codes
    
    ### Transaction Retry Errors (Class 40)
    
    | Code    | Name                                            | Action                              | When It Fires                                 |
    | ------- | ----------------------------------------------- | ----------------------------------- | --------------------------------------------- |
    | `40001` | `serialization_failure` / `restart transaction` | Retry full transaction with backoff | Concurrent SERIALIZABLE transactions conflict |
    | `40003` | `statement_completion_unknown`                  | Retry full transaction              | Ambiguous commit result (network partition)   |
    
    ### CockroachDB-Specific Errors
    
    | Code    | Name              | Action                 | When It Fires                     |
    | ------- | ----------------- | ---------------------- | --------------------------------- |
    | `XXUUU` | Internal error    | Report to CockroachDB  | Internal database error           |
    | `CR000` | CockroachDB retry | Retry full transaction | CockroachDB-specific retry signal |
    
    ### Constraint Violations (Same as PostgreSQL)
    
    | Code    | Name                    | Typical HTTP    | When It Fires                                |
    | ------- | ----------------------- | --------------- | -------------------------------------------- |
    | `23505` | `unique_violation`      | 409 Conflict    | INSERT/UPDATE violates UNIQUE or PRIMARY KEY |
    | `23503` | `foreign_key_violation` | 400 Bad Request | Referenced row does not exist                |
    | `23502` | `not_null_violation`    | 400 Bad Request | NULL in a NOT NULL column                    |
    | `23514` | `check_violation`       | 400 Bad Request | CHECK constraint failed                      |
    
    ### Connection Errors (Same as PostgreSQL)
    
    | Code    | Name                        | Action                    | When It Fires               |
    | ------- | --------------------------- | ------------------------- | --------------------------- |
    | `08000` | `connection_exception`      | Pool handles reconnection | General connection failure  |
    | `08003` | `connection_does_not_exist` | Pool handles              | Client disconnected         |
    | `08006` | `connection_failure`        | Pool handles              | Could not connect to server |
    
    ---
    
    ## Detecting CockroachDB Retry Errors
    
    See [examples/core.md](examples/core.md) for the full `isCrdbRetryError` type guard and `withCrdbRetry` helper. Key points:
    
    - Check for SQLSTATE `40001` (serialization failure) and `40003` (statement completion unknown)
    - Also check `err.message.startsWith("restart transaction")` for CockroachDB-specific retry signals
    - Constraint violations (`23xxx`) are application errors -- do NOT retry those
    
    ---
    
    ## CockroachDB Connection String Format
    
    ```
    postgresql://<username>:<password>@<host>:<port>/<database>?sslmode=verify-full
    
    # Self-hosted cluster
    postgresql://root@crdb-lb.internal:26257/mydb?sslmode=verify-full&sslrootcert=/certs/ca.crt
    
    # CockroachDB Cloud (Serverless or Dedicated)
    postgresql://<user>:<password>@<cluster-host>:26257/defaultdb?sslmode=verify-full
    
    # Multiple nodes (client-side load balancing)
    # Use a load balancer -- pg driver connects to a single host
    # Point at HAProxy/nginx that round-robins across nodes
    ```
    
    **Key differences from PostgreSQL:**
    
    - Default port is **26257**, not 5432
    - `sslmode=verify-full` is recommended for all production connections
    - CockroachDB Cloud always requires SSL
    
    ---
    
    ## Primary Key Recommendations
    
    | Strategy                                 | Recommendation           | Reason                                      |
    | ---------------------------------------- | ------------------------ | ------------------------------------------- |
    | `UUID DEFAULT gen_random_uuid()`         | **Strongly recommended** | Evenly distributes writes across all ranges |
    | `UUID` with application-generated UUIDv7 | Good                     | Time-ordered but still distributed enough   |
    | `SERIAL` / `unique_rowid()`              | **Avoid**                | Creates write hotspot on latest range       |
    | Sequential integer                       | **Avoid**                | Same hotspot issue as SERIAL                |
    | `BYTES` with hash prefix                 | Advanced                 | Good for hash-sharded indexes               |
    
    ---
    
    ## Multi-Region SQL Reference
    
    ### Database-Level Configuration
    
    ```sql
    -- Add regions to the database
    ALTER DATABASE mydb PRIMARY REGION "us-east1";
    ALTER DATABASE mydb ADD REGION "us-west1";
    ALTER DATABASE mydb ADD REGION "eu-west1";
    
    -- Set survival goal
    ALTER DATABASE mydb SURVIVE ZONE FAILURE;    -- Default: tolerate single zone loss
    ALTER DATABASE mydb SURVIVE REGION FAILURE;  -- Tolerate entire region loss (needs 3+ regions)
    ```
    
    ### Table Locality Options
    
    ```sql
    -- Regional table (data in primary region, reads/writes go there)
    ALTER TABLE users SET LOCALITY REGIONAL BY TABLE IN PRIMARY REGION;
    
    -- Regional by row (each row lives in a specified region)
    ALTER TABLE users SET LOCALITY REGIONAL BY ROW;
    -- Requires a crdb_region column: ALTER TABLE users ADD COLUMN crdb_region crdb_internal_region
    
    -- Global table (reads from any region without latency, writes are slower)
    ALTER TABLE config SET LOCALITY GLOBAL;
    ```
    
    | Locality          | Read Latency       | Write Latency                     | Use Case                       |
    | ----------------- | ------------------ | --------------------------------- | ------------------------------ |
    | REGIONAL BY TABLE | Low in home region | Low in home region                | User data homed to one region  |
    | REGIONAL BY ROW   | Low for local rows | Low for local rows                | Per-user data in user's region |
    | GLOBAL            | Low everywhere     | Higher (consensus across regions) | Config tables, reference data  |
    
    ---
    
    ## cockroach CLI Quick Reference
    
    | Command                            | Purpose                             |
    | ---------------------------------- | ----------------------------------- |
    | `cockroach start`                  | Start a node                        |
    | `cockroach init`                   | Initialize a new cluster            |
    | `cockroach sql`                    | Open SQL shell                      |
    | `cockroach node status`            | Show cluster node status            |
    | `cockroach node decommission <id>` | Gracefully remove a node            |
    | `cockroach demo`                   | Start a temporary in-memory cluster |
    | `cockroach workload init`          | Initialize sample workloads         |
    | `cockroach debug zip`              | Collect debug information           |
    | `cockroach version`                | Show version                        |
    
    ---
    
    ## Production Checklist
    
    ### Connection Management
    
    - [ ] Pool `error` event handler on every pool instance
    - [ ] `connectionTimeoutMillis` set (not default 0 = infinite wait)
    - [ ] Connection string points to load balancer, not a single node
    - [ ] SSL enabled (`sslmode=verify-full`)
    - [ ] All `pool.connect()` calls release clients in `finally` blocks
    
    ### Transaction Safety
    
    - [ ] Transaction retry logic implemented for ALL write transactions
    - [ ] Retry handles `40001` AND `40003` error codes
    - [ ] Retry handles errors from `COMMIT` (not just from statements)
    - [ ] Exponential backoff with jitter in retry loop
    - [ ] Maximum retry count is bounded (3-5 retries)
    - [ ] Read-only transactions use `AS OF SYSTEM TIME` where staleness is acceptable
    
    ### Schema Design
    
    - [ ] All primary keys use `UUID DEFAULT gen_random_uuid()`
    - [ ] No `SERIAL` or sequential integer primary keys
    - [ ] Indexes designed for distributed access patterns
    - [ ] Foreign keys reference UUID columns
    
    ### Schema Changes
    
    - [ ] DDL runs outside explicit transactions
    - [ ] One DDL statement at a time in production
    - [ ] Large backfills monitored via `SHOW JOBS`
    - [ ] Schema changes tested in staging first
    
    ### Multi-Region (if applicable)
    
    - [ ] Regions added to database
    - [ ] Survival goal set appropriately
    - [ ] Table localities configured per access pattern
    - [ ] Follower reads enabled for read-heavy tables
    
    ### Monitoring
    
    - [ ] Track `SHOW JOBS` for running schema changes
    - [ ] Monitor transaction retry rates
    - [ ] Alert on high `40001` error rates (indicates contention)
    - [ ] Monitor node health via `cockroach node status`
    - [ ] Track range distribution for hotspot detection
    
    ---
    
    _Full skill documentation: [SKILL.md](SKILL.md) | Examples: [examples/](examples/)_
    
  • SKILL.md 18.7 KB
    ---
    name: api-database-cockroachdb
    description: CockroachDB distributed SQL -- transaction retries, multi-region, online schema changes, follower reads, PostgreSQL compatibility gaps
    ---
    
    # CockroachDB Patterns
    
    > **Quick Guide:** CockroachDB connects via the standard `pg` driver (PostgreSQL wire protocol). The single most important difference from PostgreSQL: **transaction retries are mandatory**. CockroachDB's serializable isolation means any transaction can fail with SQLSTATE `40001` -- your application MUST catch this and retry the entire transaction. Use `UUID` with `gen_random_uuid()` for primary keys (never `SERIAL` -- sequential IDs cause distributed hotspots). DDL runs as online schema changes in background jobs and **cannot be inside explicit transactions**. Use `AS OF SYSTEM TIME` for follower reads to reduce latency in multi-region deployments.
    
    ---
    
    <critical_requirements>
    
    ## CRITICAL: Before Using This Skill
    
    > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
    
    **(You MUST implement transaction retry logic for SQLSTATE `40001` errors -- CockroachDB WILL return serialization errors under normal operation, unlike PostgreSQL where they are rare)**
    
    **(You MUST use `UUID` with `gen_random_uuid()` for primary keys -- NEVER use `SERIAL` or sequential IDs, which cause distributed write hotspots)**
    
    **(You MUST NOT put DDL statements inside explicit transactions -- most DDL runs as background jobs and can fail at COMMIT time with a partially applied state. `CREATE TABLE`/`CREATE INDEX` are exceptions but the safest practice is always: one DDL statement per implicit transaction)**
    
    **(You MUST use `Pool` from `pg` for all database access -- same as PostgreSQL, but be aware that each node in the cluster is a valid connection target)**
    
    </critical_requirements>
    
    ---
    
    ## Examples
    
    - [Core Patterns](examples/core.md) -- Pool setup, parameterized queries, transaction retry logic, error handling
    - [Multi-Region & Performance](examples/multi-region.md) -- Locality, survival goals, follower reads, AS OF SYSTEM TIME
    - [Schema & Operations](examples/schema-ops.md) -- Online schema changes, IMPORT INTO, CHANGEFEED, cockroach CLI
    
    **Additional resources:**
    
    - [reference.md](reference.md) -- PostgreSQL compatibility gaps, error codes, type differences, production checklist
    
    ---
    
    **Auto-detection:** CockroachDB, cockroachdb, cockroach, CRDB, crdb, cockroach_restart, SAVEPOINT cockroach_restart, 40001, serialization_failure, retry transaction, restart transaction, gen_random_uuid, unique_rowid, AS OF SYSTEM TIME, follower_read_timestamp, CHANGEFEED, CREATE CHANGEFEED, IMPORT INTO, cockroach sql, cockroach start, multi-region, survival goal, zone survival, region survival, locality, REGIONAL BY ROW
    
    **When to use:**
    
    - Direct SQL queries against CockroachDB via the `pg` driver
    - Distributed transactions requiring serializable isolation
    - Multi-region database deployments with locality-aware reads/writes
    - Applications migrating from PostgreSQL to CockroachDB
    - Change data capture with CHANGEFEED
    - Bulk data loading with IMPORT INTO
    
    **Key patterns covered:**
    
    - Transaction retry logic (SQLSTATE 40001 handling with exponential backoff)
    - UUID primary keys with gen_random_uuid() (hotspot avoidance)
    - AS OF SYSTEM TIME for follower reads and historical queries
    - Multi-region configuration (locality, survival goals, regional tables)
    - Online schema changes (DDL behavior differences from PostgreSQL)
    - PostgreSQL compatibility gaps (what does NOT work)
    
    **When NOT to use:**
    
    - You need an ORM or query builder -- use your ORM/query builder skill instead
    - You are targeting standard PostgreSQL without CockroachDB -- use the PostgreSQL skill
    - You need features CockroachDB lacks (advisory locks, full stored procedure support, CREATE DOMAIN)
    
    ---
    
    <philosophy>
    
    ## Philosophy
    
    CockroachDB is a **distributed SQL database** that uses the PostgreSQL wire protocol. The core principle: **write PostgreSQL-compatible SQL, but design for distribution.**
    
    **Core principles:**
    
    1. **Retry everything** -- Serializable isolation means any transaction can be aborted by CockroachDB to resolve conflicts. Your code MUST handle SQLSTATE `40001` and retry the full transaction. This is not an edge case -- it happens under normal load.
    2. **Distribute evenly** -- Sequential primary keys (`SERIAL`, auto-increment) create write hotspots because CockroachDB sorts data by primary key across ranges. Use `UUID` with `gen_random_uuid()` to scatter writes across the cluster.
    3. **DDL is async** -- Schema changes run as background jobs. They cannot be wrapped in explicit transactions. Plan migrations accordingly -- one DDL statement at a time in production.
    4. **Read from followers** -- Use `AS OF SYSTEM TIME` to read slightly stale data from the nearest replica instead of always hitting the leaseholder. This is the single biggest latency optimization in multi-region deployments.
    5. **PostgreSQL, mostly** -- CockroachDB supports most PostgreSQL syntax and the `pg` driver works directly. But certain features are missing or behave differently. Know the gaps before you hit them in production.
    
    </philosophy>
    
    ---
    
    <patterns>
    
    ## Core Patterns
    
    ### Pattern 1: Connection Pool Setup
    
    CockroachDB uses the standard `pg` driver. Pool setup is nearly identical to PostgreSQL, but the connection string points to a CockroachDB node (or load balancer). See [examples/core.md](examples/core.md) for full configuration.
    
    ```typescript
    // Good Example - CockroachDB pool with error handling
    import pg from "pg";
    
    const POOL_MAX_CLIENTS = 20;
    const IDLE_TIMEOUT_MS = 30_000;
    const CONNECTION_TIMEOUT_MS = 5_000;
    
    function createPool(): pg.Pool {
      const pool = new pg.Pool({
        connectionString: process.env.DATABASE_URL,
        // Example: postgresql://user:pass@crdb-lb:26257/mydb?sslmode=verify-full
        max: POOL_MAX_CLIENTS,
        idleTimeoutMillis: IDLE_TIMEOUT_MS,
        connectionTimeoutMillis: CONNECTION_TIMEOUT_MS,
      });
    
      pool.on("error", (err) => {
        console.error("Unexpected idle client error:", err.message);
      });
    
      return pool;
    }
    
    export { createPool };
    ```
    
    **Why good:** Standard pg Pool works unmodified, named constants, error handler prevents process crash, CockroachDB default port is 26257 (not 5432)
    
    ```typescript
    // Bad Example - SERIAL primary key
    await pool.query(`
      CREATE TABLE users (
        id SERIAL PRIMARY KEY,
        name TEXT NOT NULL
      )
    `);
    // SERIAL creates sequential IDs via unique_rowid()
    // which causes write hotspots on a single range
    ```
    
    **Why bad:** Sequential IDs from SERIAL/unique_rowid() cluster writes on one range, creating a hotspot that defeats CockroachDB's distributed architecture
    
    ---
    
    ### Pattern 2: Transaction Retry Logic (MANDATORY)
    
    CockroachDB's serializable isolation means transactions can fail with SQLSTATE `40001` under normal operation. You MUST catch this and retry. See [examples/core.md](examples/core.md) for the full retry helper.
    
    ```typescript
    // Good Example - Transaction with retry logic
    const CRDB_SERIALIZATION_FAILURE = "40001";
    const MAX_RETRIES = 5;
    const BASE_DELAY_MS = 50;
    
    async function withRetry<T>(
      pool: pg.Pool,
      operation: (client: pg.PoolClient) => Promise<T>,
    ): Promise<T> {
      for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
        const client = await pool.connect();
        try {
          await client.query("BEGIN");
          const result = await operation(client);
          await client.query("COMMIT");
          return result;
        } catch (err) {
          await client.query("ROLLBACK");
          if (isCrdbRetryError(err) && attempt < MAX_RETRIES) {
            const delay =
              BASE_DELAY_MS * Math.pow(2, attempt) + Math.random() * BASE_DELAY_MS;
            await new Promise((resolve) => setTimeout(resolve, delay));
            continue;
          }
          throw err;
        } finally {
          client.release();
        }
      }
      throw new Error("Retry loop exited unexpectedly");
    }
    ```
    
    **Why good:** Catches 40001 errors specifically, exponential backoff with jitter prevents thundering herd, fresh client per attempt, bounded retries, releases client in finally
    
    ```typescript
    // Bad Example - No retry logic (WILL fail in production)
    const client = await pool.connect();
    try {
      await client.query("BEGIN");
      await client.query(
        "UPDATE accounts SET balance = balance - $1 WHERE id = $2",
        [100, fromId],
      );
      await client.query(
        "UPDATE accounts SET balance = balance + $1 WHERE id = $2",
        [100, toId],
      );
      await client.query("COMMIT");
    } catch (err) {
      await client.query("ROLLBACK");
      throw err; // 40001 errors bubble up as application failures!
    } finally {
      client.release();
    }
    ```
    
    **Why bad:** No retry logic -- serialization errors (40001) propagate as unhandled application failures. In CockroachDB, these are EXPECTED under normal concurrent load, not exceptional conditions.
    
    ---
    
    ### Pattern 3: UUID Primary Keys
    
    CockroachDB distributes data across ranges sorted by primary key. Sequential IDs create hotspots. See [examples/core.md](examples/core.md) for table design patterns.
    
    ```sql
    -- Good Example - UUID primary key with gen_random_uuid()
    CREATE TABLE users (
      id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
      email TEXT NOT NULL UNIQUE,
      name TEXT NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT now()
    );
    ```
    
    **Why good:** UUIDs distribute writes evenly across all ranges in the cluster, `gen_random_uuid()` is built-in and generates UUIDv4
    
    ```sql
    -- Bad Example - SERIAL primary key
    CREATE TABLE users (
      id SERIAL PRIMARY KEY,
      email TEXT NOT NULL UNIQUE,
      name TEXT NOT NULL,
      created_at TIMESTAMPTZ NOT NULL DEFAULT now()
    );
    -- SERIAL uses unique_rowid() which generates time-ordered IDs
    -- All recent inserts land on the same range -> hotspot
    ```
    
    **Why bad:** SERIAL/unique_rowid() generates roughly time-ordered values, causing all concurrent inserts to target the same range, which bottlenecks on a single node
    
    ---
    
    ### Pattern 4: AS OF SYSTEM TIME (Follower Reads)
    
    Read slightly stale data from the nearest replica for dramatically lower latency in multi-region setups. See [examples/multi-region.md](examples/multi-region.md) for full patterns.
    
    ```typescript
    // Good Example - Follower read with built-in function
    const result = await pool.query<ProductRow>(
      "SELECT id, name, price FROM products WHERE category = $1 AS OF SYSTEM TIME follower_read_timestamp()",
      [category],
    );
    ```
    
    **Why good:** `follower_read_timestamp()` automatically picks a safe staleness window, query can be served by any replica (nearest to the client), no leaseholder round-trip
    
    **When to use:** Read-heavy dashboards, product catalogs, search results -- anywhere slightly stale data (at least 4.2 seconds) is acceptable.
    
    **When not to use:** Reads that must reflect the latest write (e.g., reading immediately after an INSERT to confirm it succeeded).
    
    ---
    
    ### Pattern 5: Online Schema Changes
    
    CockroachDB DDL runs as background jobs -- NOT inside transactions. See [examples/schema-ops.md](examples/schema-ops.md) for migration patterns.
    
    ```typescript
    // Good Example - DDL executed as individual statements
    await pool.query("ALTER TABLE users ADD COLUMN phone TEXT");
    // Runs as a background schema change job
    // Table remains fully available for reads and writes during the change
    ```
    
    **Why good:** DDL runs without table locks, no downtime, table available throughout
    
    ```typescript
    // Bad Example - DDL inside a transaction
    const client = await pool.connect();
    try {
      await client.query("BEGIN");
      await client.query("ALTER TABLE users ADD COLUMN phone TEXT");
      await client.query("ALTER TABLE users ADD COLUMN address TEXT");
      await client.query("COMMIT");
      // Most DDL can fail at COMMIT time with a partially applied state
    } finally {
      client.release();
    }
    ```
    
    **Why bad:** Most DDL in explicit transactions can fail at COMMIT time with a partially applied state. `CREATE TABLE`/`CREATE INDEX` are exceptions, but the safest practice is always one DDL statement per implicit transaction.
    
    ---
    
    ### Pattern 6: CHANGEFEED (Change Data Capture)
    
    Stream row-level changes to external sinks. See [examples/schema-ops.md](examples/schema-ops.md) for full CHANGEFEED patterns.
    
    ```sql
    -- Good Example - CHANGEFEED to Kafka
    CREATE CHANGEFEED FOR TABLE orders
      INTO 'kafka://broker:9092'
      WITH updated, resolved = '10s';
    
    -- Sinkless changefeed (streams to SQL client)
    CREATE CHANGEFEED FOR TABLE orders WITH updated;
    ```
    
    **Why good:** Real-time CDC without polling, supports Kafka/webhook/cloud storage sinks, `resolved` timestamps enable downstream consumers to know data completeness
    
    **When to use:** Event-driven architectures, data replication to analytics systems, audit logging, cache invalidation.
    
    </patterns>
    
    ---
    
    <decision_framework>
    
    ## Decision Framework
    
    ### Primary Key Strategy
    
    ```
    What type of primary key?
    +-- Need human-readable IDs? -> UUID with gen_random_uuid() + separate readable slug column
    +-- Need globally unique IDs? -> UUID with gen_random_uuid() (recommended default)
    +-- Migrating from PostgreSQL SERIAL? -> Switch to UUID, backfill existing data
    +-- Need monotonically increasing? -> DO NOT -- use UUID. If you absolutely must, use
    |                                      SERIAL but understand the hotspot tradeoff.
    ```
    
    ### Isolation Level Choice
    
    ```
    Which isolation level?
    +-- Need strongest guarantees? -> SERIALIZABLE (default, recommended)
    |   +-- Your app handles 40001 retries? -> Yes, use SERIALIZABLE
    |   +-- Cannot implement retry logic? -> Consider READ COMMITTED
    +-- Analytics / read-heavy workload? -> READ COMMITTED (no retry needed)
    +-- Background jobs with loose consistency? -> READ COMMITTED
    ```
    
    ### Read Strategy
    
    ```
    How fresh must the data be?
    +-- Must see latest writes? -> Normal read (hits leaseholder)
    +-- Stale by a few seconds is fine? -> AS OF SYSTEM TIME follower_read_timestamp()
    +-- Need a specific historical snapshot? -> AS OF SYSTEM TIME '<timestamp>'
    +-- Exporting data for analytics? -> AS OF SYSTEM TIME with follower reads
    ```
    
    ### Schema Change Strategy
    
    ```
    How to run DDL?
    +-- Single column add/drop? -> Run as individual statement (no transaction)
    +-- Multiple related changes? -> Run sequentially, one statement at a time
    +-- Need to roll back DDL? -> You cannot -- DDL is not transactional. Plan carefully.
    +-- Index creation on large table? -> All indexes are created online by default (do NOT use CONCURRENTLY -- it errors)
    ```
    
    </decision_framework>
    
    ---
    
    <red_flags>
    
    ## RED FLAGS
    
    **High Priority Issues:**
    
    - No transaction retry logic for 40001 errors -- CockroachDB WILL return these under normal concurrent load. Without retries, your application randomly fails under traffic.
    - Using SERIAL or sequential primary keys -- creates a write hotspot on a single range, bottlenecking the entire cluster on one node.
    - DDL inside explicit transactions -- most DDL can fail at COMMIT time with a partially applied state. `CREATE TABLE`/`CREATE INDEX` are exceptions, but the safest practice is one DDL per implicit transaction.
    - Using advisory locks (`pg_advisory_lock`, `pg_try_advisory_lock`) -- CockroachDB does NOT implement them. They are defined as no-op stubs that silently do nothing.
    
    **Medium Priority Issues:**
    
    - Not using `AS OF SYSTEM TIME` for read-heavy workloads in multi-region -- forces all reads to hit the leaseholder, adding cross-region latency.
    - Running multiple DDL statements simultaneously in production -- each schema change consumes resources. Run them sequentially.
    - Assuming PostgreSQL `LISTEN`/`NOTIFY` works -- CockroachDB does NOT support `LISTEN`/`NOTIFY`. Use `CHANGEFEED` for real-time change streaming.
    - Using `CREATE DOMAIN` -- not supported in CockroachDB. Use `CHECK` constraints or application-level validation.
    
    **Common Mistakes:**
    
    - Connecting to port 5432 instead of 26257 -- CockroachDB default port is 26257.
    - Expecting `SERIAL` to produce gapless sequential IDs -- CockroachDB's `unique_rowid()` produces time-ordered but non-sequential values with gaps.
    - Forgetting that `numeric`/`decimal` types return as strings in the `pg` driver (same behavior as PostgreSQL).
    - Wrapping retry logic around individual statements instead of the entire transaction -- you must retry the FULL transaction, not just the failed statement.
    - Using `SELECT ... FOR UPDATE` without understanding it acquires locks across the cluster -- it works but has higher latency than in PostgreSQL.
    
    **Gotchas & Edge Cases:**
    
    - `40001` errors can occur on `COMMIT`, not just on individual statements. Your retry loop must catch errors from `COMMIT` too.
    - CockroachDB's `SAVEPOINT cockroach_restart` is a special savepoint name that enables the advanced retry protocol. Regular savepoints (`SAVEPOINT my_savepoint`) work normally for nested rollback.
    - Temporary tables exist but are experimental (`SET experimental_enable_temp_tables = 'on'`). Creating many temp objects degrades DDL performance.
    - `READ COMMITTED` isolation is GA and enabled by default (`sql.txn.read_committed_isolation.enabled = true`), but transactions still default to `SERIALIZABLE`. Set per-transaction with `BEGIN TRANSACTION ISOLATION LEVEL READ COMMITTED`, per-session with `SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTED`, or per-database with `ALTER DATABASE db SET default_transaction_isolation = 'read committed'`.
    - CockroachDB's `pg_catalog` and `information_schema` are populated but may have differences from PostgreSQL -- some system tables have extra columns, some are missing columns.
    - `IMPORT INTO` takes the target table offline during the import. The table cannot serve reads or writes until the import completes.
    - Changefeed payload is limited. Complex JOINs or aggregations cannot be expressed directly in changefeed queries -- one table per changefeed.
    - Float overflow returns `Infinity` in CockroachDB (PostgreSQL returns an error).
    - Bitwise operator precedence differs from PostgreSQL. Use explicit parentheses.
    
    </red_flags>
    
    ---
    
    <critical_reminders>
    
    ## CRITICAL REMINDERS
    
    > **All code must follow project conventions in CLAUDE.md** (kebab-case, named exports, import ordering, `import type`, named constants)
    
    **(You MUST implement transaction retry logic for SQLSTATE `40001` errors -- CockroachDB WILL return serialization errors under normal operation, unlike PostgreSQL where they are rare)**
    
    **(You MUST use `UUID` with `gen_random_uuid()` for primary keys -- NEVER use `SERIAL` or sequential IDs, which cause distributed write hotspots)**
    
    **(You MUST NOT put DDL statements inside explicit transactions -- most DDL runs as background jobs and can fail at COMMIT time with a partially applied state. `CREATE TABLE`/`CREATE INDEX` are exceptions but the safest practice is always: one DDL statement per implicit transaction)**
    
    **(You MUST use `Pool` from `pg` for all database access -- same as PostgreSQL, but be aware that each node in the cluster is a valid connection target)**
    
    **Failure to follow these rules will cause transaction failures under load, write hotspots that defeat distribution, DDL errors, and application crashes.**
    
    </critical_reminders>
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related