Overview
A Ginboot database is one Amazon Aurora DSQL cluster, created in the AWS account you connected to Ginboot. DSQL is serverless: there are no instances to size or patch, it scales with load, and it charges nothing for compute while nothing is running.
It speaks the PostgreSQL wire protocol, so psql, GUI tools and ordinary Postgres drivers connect to it. Instead of a password you present a short-lived AWS IAM token, which the console generates for you and your applications sign for themselves.
From the console you can:
- create and delete databases,
- get connection details and a token for any Postgres client,
- browse tables and edit individual values,
- run SQL in a query editor.
DSQL is close to Postgres but not identical. Most of what differs is about how schema changes and large writes are done; the limits section lists each difference with what to do about it.
Requirements
- Your own AWS account, connected. Databases are created only in connected accounts, never in Ginboot's, so the data and the bill stay with you. Databases aren't available on the Hosted plan.
- Template version 8 or later. Version 8 of Ginboot's CloudFormation template adds the DSQL permissions, including creating the service-linked role AWS needs before an account's first cluster. If your connection is older, the console asks you to update the stack.
- Permissions. There are three:
database:create,database:connectanddatabase:delete. The Admin role has all three and the Developer role has create and connect. Deleting is left to admins because it destroys data. You can add any of them to a custom role. - A region where DSQL is available. Not every AWS region offers it yet. Create the database in the same region as the applications that use it.
AWS allows 20 single-region DSQL clusters per region per account by default. You can ask AWS for more through Service Quotas.
Create a database
- Open Databases in the console and choose New database.
- Give it a name, such as
orders, and pick the AWS region. - Choose Create. It shows as creating for a few minutes, then active.
Ginboot creates the cluster with deletion protection on, so it can't be deleted by mistake from the AWS console. Ginboot also tags the cluster with ginboot:managed, the organization and the database. Ginboot's role in your account may only change or delete clusters that carry that tag.
Connect
Open a database and choose Connect to see its connection details:
A token is only checked when a connection opens. A connection that's already open stays open after its token expires, up to DSQL's 60-minute connection limit. To open a new connection after that, choose New token.
# paste the token from the Connect dialog
PGPASSWORD='<token>' psql "host=<cluster-id>.dsql.<region>.on.aws port=5432 dbname=postgres user=admin sslmode=require"
From your application
Applications shouldn't store a token. They sign a fresh one each time they open a connection, using their own AWS credentials. To set that up:
- In the app's AWS permissions for that environment, allow
dsql:DbConnectAdminon the cluster's ARN, which the Connect dialog shows. - Set
DSQL_ENDPOINTto the host. - Have your connection pool sign a token before each new connection. Set the pool's maximum connection lifetime to under an hour, because DSQL closes connections at 60 minutes.
// go get github.com/jackc/pgx/v5 github.com/aws/aws-sdk-go-v2/feature/dsql/auth cfg, _ := pgxpool.ParseConfig("host=" + os.Getenv("DSQL_ENDPOINT") + " user=admin dbname=postgres sslmode=require") cfg.MaxConnLifetime = 50 * time.Minute // DSQL ends connections at 60 minutes cfg.BeforeConnect = func(ctx context.Context, cc *pgx.ConnConfig) error { token, err := auth.GenerateDBConnectAdminAuthToken(ctx, cc.Host, region, awsCfg.Credentials) cc.Password = token return err } pool, err := pgxpool.NewWithConfig(ctx, cfg)
The Connect dialog has the same snippet for Node.js and Python.
Tables, rows and queries
Each database's page has two tabs. Both run from Ginboot's servers as the admin role, using a short-lived token for your AWS account.
Tables
The Tables tab lists every table with its columns and types. Rows load 200 at a time, ordered by primary key.
To edit a value, click it. An edit changes exactly one row: the one with that row's full primary key. If no row matches, for example because someone else deleted it, nothing is changed and you're told so. Tables without a primary key are read-only here.
Query
The Query tab runs one statement at a time; ⌘/Ctrl + Enter runs it. A query stops after 25 seconds and shows at most 1,000 rows. For long jobs and large exports, connect a client instead.
Delete a database
Choose Delete and type the database's name to confirm. Ginboot turns off deletion protection and deletes the cluster, and every row in it, permanently. Deleting needs database:delete.
Ginboot doesn't keep a copy of your data. If you might need the data later, export it with a client before you delete.
Billing
The database runs in your AWS account, so AWS bills you for it directly. Ginboot doesn't add a charge. Databases are part of the Your AWS plan's flat $49 per organization per month.
DSQL charges for two things:
| Meter | What it measures | US East (N. Virginia) |
|---|---|---|
| Compute | Distributed Processing Units (DPUs): the reading, writing and query work your statements do | $8 / million DPUs |
| Storage | Data stored, one logical copy per region | $0.33 / GB-month |
| Free each month | Applied to your AWS bill automatically | 100,000 DPUs + 1 GB |
An idle database costs only its storage: no instance runs between queries. Rates differ by region; the Aurora DSQL pricing page has the current figures for yours.
DSQL charges for the work each statement does, not for time. Read-mostly workloads and bursty traffic usually cost little. Constant, high-volume writes such as request logs or metrics can cost more than an always-on Postgres instance. That's why our own telemetry doesn't go into DSQL.
Limits and differences from Postgres
We tested each of these against DSQL ourselves in October 2026. The limits also match AWS's published quotas. DSQL is changing quickly, so check AWS's documentation for anything added since.
| Area | On DSQL | What to do |
|---|---|---|
| Indexes | CREATE INDEX is refused. Only CREATE INDEX ASYNC works, and it builds in the background. | Use ASYNC. A unique index doesn't enforce uniqueness until its build has finished. |
| Schema changes | One DDL statement per transaction, and DDL and data changes can't share a transaction. | Run migrations one statement at a time, outside any transaction that writes data. |
| Adding columns | Only a plain nullable column. ADD COLUMN with DEFAULT, NOT NULL or any other constraint is refused. | Add the column bare, then fill existing rows in batches. DROP COLUMN works. |
| Changing a column's type | ALTER COLUMN … TYPE is refused. | Add a new column, copy the values in batches, switch the application over, then drop the old one. |
| Rows per transaction | At most 3,000 rows inserted, updated or deleted. In our tests, secondary index entries didn't count towards this. | Batch bulk writes and deletes. A big DELETE or DROP SCHEMA … CASCADE fails until you split it. |
| Transaction size and age | At most 10 MiB of changed data, and 5 minutes. | Keep transactions short. Large text or JSON rows reach the size limit before the row limit. |
| Concurrency | Optimistic. Two transactions writing the same rows both proceed, and the second to commit fails with SQLSTATE 40001. | Retry the whole transaction on 40001, as shown below. |
| Connections | IAM tokens instead of passwords. Connections close after 60 minutes. One database, postgres, per cluster. | Sign a token per connection and cap your pool's connection lifetime under an hour. |
Many limits that older articles describe are gone. Foreign keys are stored and enforced (since August 2026), jsonb works as a column type with its operators (since June 2026), and INSERT … ON CONFLICT works.
Retrying conflicts
On DSQL, a conflict surfaces at COMMIT, so retry the whole transaction, not the last statement. Everything inside the retried function must be safe to run again: send emails and call other services only after it commits.
for attempt := 1; ; attempt++ { err := pgx.BeginFunc(ctx, pool, func(tx pgx.Tx) error { // every statement of the transaction, and nothing outside the database return nil }) var pgErr *pgconn.PgError if errors.As(err, &pgErr) && pgErr.Code == "40001" && attempt < 5 { time.Sleep(time.Duration(attempt*attempt*20+rand.IntN(20)) * time.Millisecond) continue } return err }
Adding a column with a default
ALTER TABLE orders ADD COLUMN status text; -- no DEFAULT, no NOT NULL -- then, in its own transaction, repeated until it updates 0 rows UPDATE orders SET status = 'pending' WHERE id IN (SELECT id FROM orders WHERE status IS NULL LIMIT 1000);
New rows also need the value. Many ORMs write a field's default on insert themselves. If yours doesn't, set the value in your code.
Move an existing Postgres database
A plain pg_dump and restore won't load into DSQL as is. The dump creates indexes synchronously, and it loads each table in a single statement, which breaks the 3,000-row limit for any table bigger than that. Move the schema and the data separately instead.
1. Check the schema
Compare your schema with the limits above. Look for types or extensions your app relies on, and for every table without a primary key. A primary key lets you copy a table in pages and rerun the copy safely. Also look for columns your code no longer uses. ORM migrations seldom drop old columns, and there's no reason to carry them over.
2. Create the schema
Create the tables first, one statement each, then the indexes with CREATE INDEX ASYNC. Wait for the indexes to build before relying on unique ones. If an ORM manages your schema, make sure it creates everything, including many-to-many join tables, which often have no model of their own.
3. Copy the data
Copy parent tables before the tables that reference them, because DSQL enforces foreign keys. Read each table in primary-key order, a page at a time, and write each page as one transaction:
-- read from Postgres, after the last key of the previous page SELECT id, customer_id, total FROM orders WHERE id > $1 ORDER BY id LIMIT 1000; -- write to DSQL as an upsert, so the copy can be rerun safely INSERT INTO orders (id, customer_id, total) VALUES ($1, $2, $3), ($4, $5, $6) -- … up to 1,000 rows ON CONFLICT (id) DO UPDATE SET customer_id = EXCLUDED.customer_id, total = EXCLUDED.total;
Because every page is an upsert, running the copy again finishes an interrupted run and brings changed rows across. Keep pages small enough for both the 3,000-row and the 10 MiB limits, and retry a page that fails with 40001.
4. Verify and switch over
- Compare row counts table by table.
- Stop writes to the old database, run the copy once more, and check the counts again. Rows deleted from the source since the first copy show up here as differences.
- Point your application at DSQL. Before this step, make sure it signs IAM tokens and retries
40001.
Keep the old database until you've watched the application run on DSQL for a while.
What we learned moving our own database
We're moving Ginboot Cloud's own control-plane database, the one behind this console, from Postgres to DSQL. It holds organizations, apps, builds, deployments and permissions: 43 tables, built with the GORM ORM. So far, our test copy has moved every row into a DSQL cluster and matched every table's row count. The production switch is still ahead of us. This is what we've learned so far, as of October 2026.
-
Most of the schema work was indexes
Our ORM's schema came to 151 statements, and DSQL refused 106 of them. All 106 were index creations, and every one worked once rewritten as
CREATE INDEX ASYNC. All the tables, column types, foreign keys andjsonbcolumns were accepted unchanged. -
Test the changes you'll make next year, not just today's schema
Creating the schema from scratch was the easy part. Adding a column with a default, the most common migration we ship, is refused on DSQL. We now add such columns bare and fill existing rows in batches. That works because our ORM writes defaults on insert itself. Check that yours does the same before you rely on it.
-
Every transaction needs to be safe to retry
We wrapped all 24 of our transactions in a retry on
40001, then checked each one. None of them sends email or calls AWS from inside the transaction, so running one again is harmless.Six were turning database errors into friendly messages such as "email already registered". A conflict passing through one of them became that message, which hid it from the retry. Pass conflicts through untouched.
-
Keep high-volume writes out of DSQL
Request logs, metrics and traces are append-only, written constantly and deleted in bulk. DSQL charges for every write and limits each transaction to 3,000 rows, which makes that workload expensive and awkward. Ours go to a dedicated telemetry store, Grafana Cloud, so the database only holds what the console manages.
-
Your catalog queries may read DSQL wrong
DSQL stores each table inside its primary-key index, and that index lists every column as an
INCLUDEcolumn. Code that treats every column inpg_index.indkeyas part of the key concludes that the key is the whole row. Our copy tool did, and its first upsert failed. Only the firstindnkeyattscolumns are the key. -
Ask the copy to refuse instead of guess
Our copy stops when the target lacks a column or table the source has, unless you've told it explicitly to leave that data behind. That check found five columns our ORM had stopped using but never dropped.
It also found a missing many-to-many join table. Our migration code built each model's table but never the join tables between models. Copying around it would have arrived with every custom role stripped of its permissions.
-
The first cluster in an account needs a service-linked role
Before AWS creates the first DSQL cluster in an account, the caller must be allowed to create DSQL's service-linked role. That is why databases need version 8 of our CloudFormation template: version 7 didn't have that permission.