"It's HBase-compatible, so it's basically a connection string change." I have heard this sentence, or a version of it, at the start of every HBase-to-Bigtable migration I've been near, and it is true in a way that makes it more dangerous than if it were simply false. Swap the client jar, point at a Bigtable instance, and a well-behaved HBase application really does compile and run. The demo works. Then you get to the table with the coprocessor in it, and the migration stops being a connection string.

Bigtable is the right destination for most HBase workloads leaving a Hadoop cluster — I'd say that plainly. But the API compatibility is a bridge, not an equivalence, and knowing exactly where the bridge ends is the difference between a boring cutover and a surprise rewrite discovered in week six. This piece continues the [Cloudera exit](cloudera-hadoop-gcp-dataproc) story: once [Dataproc](gcp-dataproc-architecture) is running your Spark and Hive, the HBase tables are the workload that doesn't want to come along quietly.

## What is Bigtable, architecturally?

Cloud Bigtable is a managed wide-column NoSQL store where tables are sharded by row-key range into **tablets**, and those tablets are stored on **Colossus** — Google's distributed filesystem — in **SSTable** format. The single most important structural fact, and the one that explains most of the behavioral differences from HBase: *data is never stored on Bigtable nodes*. A node holds pointers to tablets that live on Colossus.

Sit with that for a second, because if you know [HBase internals](hbase-internals) it rearranges your intuitions. In HBase, a RegionServer owns regions whose HFiles live in HDFS — and while HDFS is technically separate, the deployment co-locates them for data locality, so losing a RegionServer means reassignment plus a locality penalty until compaction re-localizes. In Bigtable, rebalancing a tablet from one node to another is a pointer change. No data moves. That's why Bigtable can rebalance aggressively and transparently in response to load, and why adding nodes gives you nearly linear throughput almost immediately rather than after a long redistribution.

```mermaid
flowchart TB
    C["Client — HBase API or native client"] --> N1
    subgraph NODES["Bigtable nodes — pointers only, no data"]
        N1["Node 1serves tablets A, B"]
        N2["Node 2serves tablets C, D"]
    end
    subgraph COLOSSUS["Colossus — where data actually lives"]
        T1[("Tablet ASSTables")]
        T2[("Tablet BSSTables")]
        T3[("Tablet CSSTables")]
        T4[("Tablet DSSTables")]
        LOG[("Shared logdurability on ack")]
    end
    N1 -.pointer.-> T1
    N1 -.pointer.-> T2
    N2 -.pointer.-> T3
    N2 -.pointer.-> T4
    N1 --> LOG
    N2 --> LOG
    N2 -.->|"rebalance = move a pointer,not the data"| T2
          
```

Nodes serve tablets they don't own. Because rebalancing only moves a pointer, Bigtable redistributes load in seconds and scales throughput roughly linearly with node count — the property HBase's co-located design can't match. Writes hit Colossus's shared log before acknowledgement, which is where durability comes from.

The rest of the model will feel familiar because it's the same lineage — the 2006 Bigtable paper is what HBase was built to clone. Sorted rows, column families, cells versioned by timestamp, an LSM-shaped write path (memtable → flush → SSTable → background compaction) of the kind I went through in the [B-tree vs LSM](btree-vs-lsm-storage-engines) and [compaction strategies](lsm-compaction-strategies) pieces. Bigtable just runs all of that for you and doesn't let you tune it.

## Why does row-key design still decide everything?

The row key is the only index Bigtable has, and it determines both how rows are stored and how load spreads across nodes — which means a bad row key is not a tuning problem, it's an architecture problem you can't fix with more nodes. This is the one piece of HBase expertise that ports over completely, so the good news is your instincts are correct.

**Hotspotting** is the failure mode. Because tablets are contiguous row-key ranges, keys that increase monotonically — a timestamp prefix, a sequential ID — send every new write to the same range, therefore the same tablet, therefore one node. You can run a 30-node instance and saturate exactly one of them. The classic remedies are unchanged from HBase: hash or reverse a prefix component, salt with a bucket, or promote a higher-cardinality field to the front of the key.

| Row key | What happens |
| --- | --- |
| 1722859200#device42 (timestamp first) | Every write lands in the newest range. One hot node. The classic mistake. |
| device42#1722859200 (field promoted) | Writes spread across devices; time-range scans *per device* stay contiguous. Usually the right answer. |
| a3f#device42#1722859200 (hash-salted) | Spreads well; but a scan across all devices now needs one read per salt bucket. |
| reverse(device_id)#ts | Breaks up sequential device IDs; costs you any prefix-scan locality on device ranges. |

The trade-off is always the same and always worth stating out loud before someone picks a key in a hurry: **anything you do to spread writes also spreads reads**. Salting fixes the hotspot and breaks the single-scan retrieval you designed the key for. Promoting a field is usually the cleanest resolution because it distributes on a dimension you actually query by.

## What has no equivalent on the other side?

Here's the honest inventory — the things that make "it's just a connection string" false. Most of it comes from the same root cause: Bigtable is a managed service, so it took away the server-side extension points and the tuning knobs, and it optimizes those for you instead.

| HBase feature | Bigtable | What to do |
| --- | --- | --- |
| **Coprocessors** (server-side logic) | Nothing equivalent — you cannot run your code on the servers | The real rewrite. Move logic client-side, into Dataflow, or into a service. Budget for this. |
| Custom region split policies, manual splits | Bigtable splits and rebalances automatically | Delete the code. Pre-splitting hints exist for bulk loads; otherwise let it manage. |
| Per-table/CF server-side tuning (block size, compaction, memstore) | Managed — the knobs are gone | Delete the DDL properties. This is genuinely fine, and it feels wrong for a month. |
| HBase shell admin, HDFS-level ops | cbt CLI, console, APIs | Retrain runbooks. Ops surface is much smaller. |
| Multi-row transactions | **Single-row atomicity only** — same as HBase | Nothing changes. But if you were faking cross-row txns with coprocessors, see row one. |
| Old HBase API versions (0.9x) | Client targets HBase 1.x / 2.x APIs | Upgrade the application's API usage first, on-prem, before migrating. |

**Coprocessors are the migration's hidden schedule risk.** Everything else on that list is deletion or config. Coprocessors are a rewrite, and they're insidious because they're invisible from the client side — the application code that calls the table looks perfectly portable, and the logic that has no home on GCP is buried in a JAR someone deployed to the RegionServers in 2017. Grep for coprocessor registrations in your table descriptors on day one, not in month two. Every HBase migration I've watched slip has slipped here, and the estimate was always made before anyone knew the coprocessors existed.

## How do I actually move the data?

Two paths, and the choice is entirely about whether you can tolerate downtime.

### Offline: snapshot → GCS → Dataflow import

The simple one. Export an HBase snapshot per table to Cloud Storage, then run a Dataflow job that reads the snapshot files and writes them into Bigtable. Google ships a Schema Translation Tool that connects to HBase, reads your table schemas, and creates the matching tables in Bigtable, plus a Migration Validation Tool to prove the two sides agree afterwards. If you can take a maintenance window long enough to snapshot, copy, and import, do this — it's dramatically less machinery than the alternative.

### Online: live migration via the replication library

When downtime isn't on the table, the HBase-Bigtable replication library uses HBase's own replication mechanism to stream live writes into Bigtable while the bulk snapshot import is still running — and, crucially, sequences the two correctly so a replicated live write isn't clobbered by a late-arriving bulk row. This is the part you very much want to use a supported tool for rather than hand-roll; ordering bulk-versus-live correctly is the kind of problem that looks solved right up until you find the rows it silently lost.

```mermaid
flowchart LR
    HB[("HBase clusterstill serving prod")] -->|"1 · snapshot"| GCS[("Cloud Storagesnapshot files")]
    GCS -->|"2 · Dataflow import"| BT[("Bigtable")]
    HB -->|"3 · replication librarylive writes, sequenced"| BT
    BT -->|"4 · validation tool"| OK["Rows match"]
    OK -->|"5 · cutover reads"| APP["Application"]
    APP -.->|"6 · cutover writes,retire HBase"| BT
          
```

The live path: bulk-load history from a snapshot while replication streams current writes into the same tables, validate that both sides agree, then move reads and finally writes. Steps 3 and 2 running concurrently is exactly why the sequencing has to be someone else's solved problem rather than your clever script.

The client-side change, once data is flowing, really is small — which is what started the whole "just a connection string" idea. Swap the HBase client dependency for the Bigtable HBase client and the Connection is configured against a Bigtable instance instead of ZooKeeper:

```java
// The HBase-compatible client: same Table/Get/Put/Scan API you already use.
// No ZooKeeper quorum — an instance id instead.
Configuration conf = BigtableConfiguration.configure("my-project", "my-instance");
try (Connection connection = BigtableConfiguration.connect(conf)) {
    Table table = connection.getTable(TableName.valueOf("sensor_readings"));

    // Row key: device first, timestamp second — spreads writes across tablets
    // while keeping a single device's history contiguous for scans.
    Put put = new Put(Bytes.toBytes("device42#1722859200"));
    put.addColumn(Bytes.toBytes("cf1"), Bytes.toBytes("temp"), Bytes.toBytes("21.5"));
    table.put(put);
}
```

## What do you get that HBase never gave you?

It's worth ending the inventory on the other side of the ledger, because the managed service isn't only subtraction. **Replication** across clusters and regions is a configuration rather than a project, with **app profiles** deciding whether a workload routes single-cluster (for consistency) or multi-cluster (for availability) — that one lever solves the "serve reads near the user without building a second stack" problem that was miserable on-prem. **Autoscaling** adjusts node count to load. And there's no NameNode, no RegionServer to restart at 3am, no compaction tuning spreadsheet, and no HDFS to babysit. For the teams I've moved, the operational surface shrinking is what they notice six months later, not the latency numbers.

## What to carry away

The HBase API compatibility is real and it is genuinely the reason this migration is tractable — but treat it as a bridge for your *application code*, not a statement about your *architecture*. Underneath, Bigtable is a different shape: nodes hold pointers, not data, so rebalancing is instant and scaling is close to linear; the row key is still the only index and still the only thing that decides whether you have one hot node or thirty busy ones; and the server-side extension points are gone, so any coprocessor you own is a rewrite you should find in week one rather than week six. Pick the snapshot path if you can take a window and the replication library if you can't, validate with the tool rather than a hopeful count, and let go of the tuning knobs — the discomfort of not being able to configure compaction fades faster than you'd expect, and what replaces it is a database that nobody has to get up at 3am for.
