A marketer renames a HubDB column to fix a typo. Three CMS pages break instantly because their HubL templates referenced the old name. The fix takes 15 minutes; the SEO drop from 502 errors during the Google crawl window takes a quarter to recover. HubDB has no formal migration tooling, so you build the discipline yourself.
Treat HubDB like a production database
HubDB powers public pages. A schema change is a deploy, not a tweak. Apply software-engineering hygiene:
- Version your schema in a separate file checked into source control
- Never rename a column in place; add new, dual-write, deprecate, drop
- Test on a clone table before touching production
- Communicate the change window to anyone who edits pages
Schema-as-code for HubDB
HubDB does not natively store schema in a repo, but you can sync it via the API. Maintain hubdb-schema/{table}.json:
{
"name": "products",
"label": "Products",
"useForPages": true,
"columns": [
{ "name": "name", "type": "TEXT", "label": "Product Name" },
{ "name": "slug", "type": "TEXT", "label": "URL Slug" },
{ "name": "price", "type": "NUMBER", "label": "Price USD" },
{ "name": "is_active", "type": "BOOLEAN", "label": "Active" }
]
}
A nightly job diffs this against the live schema and posts deltas to Slack. Drift gets caught the next morning, not three months later.
Adding a column safely
1. Add column with a default value
2. Backfill existing rows
3. Update templates to read new column with fallback
4. Verify rendered output across pages
5. Mark old column deprecated in label
6. Wait one publishing cycle
7. Drop old column
The fallback in step 3 protects you during the dual-write window:
{% set price = row.price_v2 or row.price %}
<span>${{ price }}</span>
Renaming requires the same dance
There is no rename — only add new, copy, switch reads, drop old:
// Migration script run via API
const rows = await hubdb.getTableRows(tableId);
for (const row of rows) {
await hubdb.updateRow(tableId, row.id, {
values: { product_name: row.values.name } // copy to new column
});
}
await hubdb.publishTable(tableId);
Then update every template referencing row.name to use row.product_name, verify, drop the old column.
Type changes are migrations, not edits
Changing a column from TEXT to NUMBER drops non-numeric data. HubSpot warns you, but the warning gets clicked through. Before any type change, export the table as CSV, verify every row converts cleanly, then proceed:
Before TEXT -> NUMBER:
SELECT * FROM table WHERE column NOT MATCHING ^-?[0-9]+(\.[0-9]+)?$
Fix or remove non-conforming rows
Re-export, then run the type change
Publishing semantics
HubDB has draft and published states. Templates can read either. Pin every production query to published:
{% set rows = hubdb_table_rows(table_id, "is_active=true&orderBy=sort") %}
Without explicit publish handling, mid-edit drafts can leak to live pages or the cache pulls stale data after a publish. Always publish the table after any schema or data change.
Rollback plan
Every migration needs a backout. Keep the previous schema JSON in git, keep a CSV snapshot of every row taken before the migration, and document the exact API calls to reverse:
Backout for v23:
1. Restore schema from /hubdb-schema/products-v22.json
2. Restore rows from /backups/products-2026-04-28.csv
3. Republish table
4. Purge CDN cache
What to do this week
Export your top three HubDB tables to versioned JSON, commit them, and write a one-page migration runbook covering the add-backfill-switch-drop pattern before the next schema change goes through.