Book a workshop seat, check availability, and cancel a booking with FastAPI, SQLAlchemy, psycopg, and Alembic on ClickHouse Managed Postgres. The example demonstrates a small but important promise: two concurrent requests cannot take the same last seat. FastAPI's generated OpenAPI interface lets you explore the application without building a separate frontend.
Read the companion guide: Build a workshop booking API with FastAPI, SQLAlchemy, and Postgres.
Sign up for ClickHouse Cloud with $300 in free trial credits.
ClickHouse Cloud is the overall managed data platform. Within it, ClickHouse is the analytical database service, and ClickHouse Managed Postgres is the PostgreSQL service for transactional applications.
ClickHouse Managed Postgres runs on local NVMe storage, with managed backups and
options for high availability. It integrates with the ClickHouse analytical
database through ClickPipes CDC and the pg_clickhouse extension. Together, the
two database services provide a unified transactional and analytical data stack
within ClickHouse Cloud.
This application uses only the ClickHouse Managed Postgres service: bookings need transactions, constraints, and current availability. Shortwave provides the complete example and explicit setup commands for using both database services with ClickPipes.
flowchart LR
Client[curl or OpenAPI interface] --> API[FastAPI]
API --> ORM[SQLAlchemy + psycopg]
ORM -->|verified TLS| PG[ClickHouse Managed Postgres]
Alembic[Alembic + reviewed SQL files] --> PG
| Route | Purpose | Success |
|---|---|---|
GET /workshops |
List upcoming workshops and available seats | 200 |
POST /bookings |
Book one seat using { "id": "UUID", "workshop_id": "UUID" } |
201, or 200 on retry |
GET /bookings/{id} |
Read your booking | 200 |
DELETE /bookings/{id} |
Cancel your booking | 200 |
The booking routes require a bearer token. The server maps tokens to seeded
attendees; callers cannot choose a different owner in the request. Another
attendee's booking returns 404 on read or cancellation. The workshop list is
public and contains no attendee information.
Three tables live in the workshop_booking schema: workshops, attendees, and
bookings. To book a seat, the API locks the workshop row, checks the current
active booking count, and inserts the booking in the same transaction.
Cancellation acquires the same workshop lock. A partial unique index permits at
most one active booking per attendee and workshop. All writes to bookings must
follow this locking protocol; the capacity rule is a transaction rule, not a
cross-table database constraint.
Use a ClickHouse Cloud account with Managed Postgres access, Git, curl, jq,
OpenSSL, psql 15 or newer, and Python 3.12. Run application dependencies in a
Linux development environment. On macOS, maintainers can create an
isolated OrbStack machine:
orb create --isolated --isolate-network ubuntu:24.04 workshop-booking-dev
orb -m workshop-booking-devThis disables host file sharing and integration, SSH-agent forwarding, and access from the machine to the host and other machines. No host mounts are needed. Keep Cloud API credentials on the machine you use for provisioning; transfer only the database credentials needed for each step into Linux. The commands below use Bash and run from the application directory unless noted.
sudo apt-get update
sudo apt-get install -y python3 python3-venv ca-certificates git curl jq openssl postgresql-client
git clone https://github.com/ClickHouse/examples.git
cd examples/applications/workshop-booking
python3 --version
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txtThe requirements file pins the dependency set. There is no Node.js installation or JavaScript build step.
Run these provisioning commands where you manage Cloud resources. Install clickhousectl, then sign in with an Admin API key created using the Cloud API instructions. The interactive prompt keeps the secret out of shell history. OAuth is read-only for Postgres provisioning.
curl -fsSL https://clickhouse.com/cli | sh
export PATH="$HOME/.local/bin:$PATH"
clickhousectl --version
clickhousectl cloud auth login --interactive
clickhousectl cloud auth status
clickhousectl cloud org list
umask 077
mkdir -p .deploymentIn an editor, create .deployment/resources.env with your organization ID and
a region and size supported by your organization:
CH_ORG_ID=YOUR_ORGANIZATION_UUID
DEPLOYMENT_NAME=workshop-booking-example
CLOUD_REGION=YOUR_AVAILABLE_AWS_REGION
PG_SIZE=YOUR_AVAILABLE_POSTGRES_SIZEReview Managed Postgres pricing before creation. This example provisions one Postgres 18 service without HA. Compute, storage, backups, and network usage can incur charges. Stopping Python or the development VM does not stop database charges; delete an unneeded dedicated service when finished.
Run creation once. Preserve the response privately: it contains the initial administrator password.
source .deployment/resources.env
clickhousectl cloud postgres create \
--org-id "$CH_ORG_ID" --name "$DEPLOYMENT_NAME" \
--provider aws --region "$CLOUD_REGION" --size "$PG_SIZE" \
--pg-version 18 --ha-type none \
--tag project=workshop-booking --tag environment=development --json \
> .deployment/postgres-create.json
PG_SERVICE_ID="$(jq -er '.id' .deployment/postgres-create.json)"
printf 'PG_SERVICE_ID=%s\n' "$PG_SERVICE_ID" >> .deployment/resources.envRepeat get until state is running; do not repeat create:
clickhousectl cloud postgres get "$PG_SERVICE_ID" \
--org-id "$CH_ORG_ID" --json > .deployment/postgres-status.json
jq '{id, state, hostname, username, postgresVersion, size}' \
.deployment/postgres-status.jsonIf creation was interrupted, first reconcile against
clickhousectl cloud postgres list --org-id "$CH_ORG_ID" --json. A missing local
response does not mean the service was not created. Retrieve the CA only after
the service is running:
clickhousectl cloud postgres certs get "$PG_SERVICE_ID" \
--org-id "$CH_ORG_ID" --output .deployment/postgres-ca.pemUse --output to obtain a PEM certificate file. JSON-mode output redirected into
a .pem file is not a usable CA. Record the returned direct hostname and
administrator username in .deployment/resources.env:
PGHOST=YOUR_POSTGRES_HOSTNAME
PGPORT=5432
PGDATABASE=postgres
PGADMIN=YOUR_SERVICE_ADMIN_USERNAMEUse the service's direct Postgres endpoint for both migrations and runtime. If using an isolated VM, copy the private provisioning receipt and CA only for database setup, then remove administrator credentials from the runtime copy. Do not copy the Cloud API credential store into the VM.
The following commands run in Linux, where psql, Python, and the application
files are installed. Generate passwords once and preserve the file on retries:
umask 077
mkdir -p .deployment
cat > .deployment/passwords.env <<PASSWORDS
WORKSHOP_MIGRATOR_PASSWORD=Aa1_$(openssl rand -hex 30)
WORKSHOP_APP_PASSWORD=Aa1_$(openssl rand -hex 30)
PASSWORDS
source .deployment/resources.env
source .deployment/passwords.env
export PGHOST PGPORT PGDATABASE WORKSHOP_MIGRATOR_PASSWORD WORKSHOP_APP_PASSWORD
export PGSSLMODE=verify-full
export PGSSLROOTCERT="$PWD/.deployment/postgres-ca.pem"
export PGPASSWORD="$(jq -er '.password' .deployment/postgres-create.json)"
psql -X -v ON_ERROR_STOP=1 -U "$PGADMIN" -f sql/bootstrap.sqlThe bootstrap creates a non-login schema owner, a migration login, and a
separate runtime login. Passwords are read through psql's \getenv and quoted
as SQL values, rather than embedded in SQL files or process arguments.
Run the Alembic migration with the migration login:
export PGUSER=workshop_booking_migrator
export PGPASSWORD="$WORKSHOP_MIGRATOR_PASSWORD"
.venv/bin/alembic upgrade head
.venv/bin/alembic current
psql -X -v ON_ERROR_STOP=1 -f sql/seed.sql
unset PGPASSWORD| File | Responsibility |
|---|---|
| sql/bootstrap.sql | Database roles and schema ownership |
| sql/migrations/001_initial.sql | Tables, constraints, and indexes |
| migrations | Alembic revision and migration environment |
| sql/grants.sql | Runtime permissions |
| sql/seed.sql | Demo attendees and upcoming workshops |
| sql/cleanup.sql | Explicit removal of this example's objects |
Alembic applies the reviewed schema and grants SQL and records the revision in Postgres.
Run alembic upgrade head when resuming or upgrading; do not apply the initial
SQL separately and bypass migration tracking. Bootstrap runs once and fails if
its objects already exist. Seed data can be reapplied. Never execute
the whole SQL directory, which also contains destructive cleanup.
Create a runtime environment file containing only the application login and attendee tokens. See .env.example for all options and the demo attendee IDs from sql/seed.sql.
cp .env.example .deployment/app.envEdit app.env: set PGHOST, PGPORT, PGDATABASE, PGUSER=workshop_booking_app,
PGPASSWORD to WORKSHOP_APP_PASSWORD, and PGSSLROOTCERT to the absolute
downloaded CA path. Generate a separate token for each attendee:
openssl rand -hex 32Put the tokens in API_BEARER_TOKENS, a JSON object mapping an attendee UUID to
its token. Keep the same UUID when rotating a token so existing bookings remain
owned by that attendee. Every configured attendee must exist in the database;
startup checks this. These tokens are server-to-server credentials, not public
browser keys. Restart the process after changing the configuration.
The application builds a SQLAlchemy connection using psycopg and forces
sslmode=verify-full: both the certificate chain and server hostname must verify
against the supplied CA. Separate PG* fields avoid password URL-encoding traps.
Do not disable TLS verification to work around a connection failure.
Load the runtime file and start the server:
set -a
source .deployment/app.env
set +a
.venv/bin/uvicorn app.main:app --host 127.0.0.1 --port 8000The application checks its connection before accepting requests. Explore the
generated interface at http://127.0.0.1:8000/docs and the schema at
http://127.0.0.1:8000/openapi.json from the environment running the server.
For an isolated VM, the commands below can all run in a second VM shell.
In a second shell, load app.env as above and select the first configured token:
BASE_URL=http://127.0.0.1:8000
API_TOKEN="$(printf '%s' "$API_BEARER_TOKENS" | jq -er 'to_entries[0].value')"
curl --fail-with-body -sS "$BASE_URL/workshops" > .deployment/workshops.json
jq . .deployment/workshops.jsonChoose a workshop ID from that response and generate one booking ID. Preserve the booking ID when retrying a request after a timeout or lost response:
WORKSHOP_ID=UUID_FROM_WORKSHOPS_RESPONSE
BOOKING_ID="$(.venv/bin/python -c 'import uuid; print(uuid.uuid4())')"
jq -n --arg id "$BOOKING_ID" --arg workshop "$WORKSHOP_ID" \
'{id: $id, workshop_id: $workshop}' > .deployment/booking-request.json
curl --fail-with-body -sS -i "$BASE_URL/bookings" \
-H "Authorization: Bearer $API_TOKEN" \
-H 'Content-Type: application/json' \
--data-binary @.deployment/booking-request.jsonA new booking returns 201 with status: "confirmed". Repeat the exact request:
it returns 200 with the same booking, without consuming another seat.
Reusing the ID for another existing workshop returns 409. A new ID for an attendee
who already has an active booking in that workshop also returns 409.
curl --fail-with-body -sS "$BASE_URL/bookings/$BOOKING_ID" \
-H "Authorization: Bearer $API_TOKEN" | jq
curl --fail-with-body -sS -X DELETE "$BASE_URL/bookings/$BOOKING_ID" \
-H "Authorization: Bearer $API_TOKEN" | jqCancellation returns 200, status: "cancelled", and a non-null cancelled_at.
Availability increases by one. Repeating DELETE returns the cancelled booking
and leaves availability unchanged. Retrying the original POST after cancellation
returns the current cancelled booking; it never silently makes a new booking.
Use a new booking UUID to book again.
The seed includes a workshop with one seat. Booking it as one attendee and
trying as another returns 409 for the second request. Sending the two requests
concurrently must produce exactly one confirmed booking. Missing/invalid tokens
return 401; invalid UUIDs and unexpected body fields return 422.
Booking request bodies are limited to 4 KiB, including streamed requests; larger
bodies return 413. The workshop list accepts limit from 1 to 100 (default
20) and offset from 0 to 10,000, ordered by start time and ID.
Install test dependencies inside Linux and run the fast checks:
.venv/bin/python -m pip install -r requirements-dev.txt
.venv/bin/pytest tests/unitThe integration tests use a real Postgres service to exercise competing requests, retry semantics, cancellation, authorization, rollback, constraints, and TLS. Run them against a dedicated example service with the fixture credentials described in tests/README.md. They create and remove their own fixtures. Keep privileged test credentials out of the runtime file.
- This is a small authenticated API with seeded attendees and workshops. It has no signup flow, payment handling, calendar integration, waitlist, or admin UI.
- A booking is one seat. An attendee can have only one active booking in a workshop. New bookings and first-time cancellations close when the workshop starts. Retrying an existing booking or an already completed cancellation still returns its current state.
- The engine explicitly uses
READ COMMITTEDisolation and a consistent workshop-first lock order protect the capacity check. Do not remove the lock when adding another writer or an administrative booking endpoint. List availability is a snapshot; the booking transaction makes the final admission decision. - The pool permits five persistent and five overflow connections per process. Multiply that limit by the number of worker processes or replicas before scaling. Requests waiting too long on the database return a retryable error; retry creation with the same booking ID.
- Bookings, including cancelled records, are retained. Deleting them would remove the durable retry history, so define a retry window before adding retention.
- Authorization is enforced in the API. The shared runtime database role is not a separate database tenant boundary. Use only the migration login for schema changes and never run the application with the administrator password.
- For hosted operation, run Uvicorn under a process supervisor behind HTTPS, with request limits and appropriate network access. The development listener is bound to localhost. This example does not provision an application host.
For TLS errors, verify the downloaded file is PEM, the CA is current, and
PGHOST matches this service. For authentication errors, distinguish the Cloud
API key, database administrator, migrator, application password, and HTTP token.
A failed startup can also mean a configured attendee was not seeded.
If the administrator password is lost, reset it on the recorded service instead of creating another service:
clickhousectl cloud postgres reset-password "$PG_SERVICE_ID" \
--org-id "$CH_ORG_ID" --generate --json \
> .deployment/postgres-password-reset.jsonKeep that response private. Resetting the administrator password does not rotate the separate application login.
Stop Uvicorn with Ctrl-C. To remove the example from a service you will keep, review sql/cleanup.sql, reconnect with administrator credentials, and run the file explicitly:
export PGPASSWORD="$(jq -er '.password' .deployment/postgres-create.json)"
psql -X -v ON_ERROR_STOP=1 -U "$PGADMIN" -f sql/cleanup.sql
unset PGPASSWORDUse the reset password if it has changed. Cleanup deletes this example's data and roles. It does not delete the Managed Postgres service or stop its charges. For a service created exclusively for this example, check the saved ID, then delete it:
source .deployment/resources.env
clickhousectl cloud postgres get "$PG_SERVICE_ID" --org-id "$CH_ORG_ID" --json
clickhousectl cloud postgres delete "$PG_SERVICE_ID" --org-id "$CH_ORG_ID"
clickhousectl cloud postgres list --org-id "$CH_ORG_ID" --jsonConfirm the recorded ID is absent from the service list. Preserve receipts until the outcome is clear, then remove unneeded private credential files and the dedicated VM. Never delete a shared service to clean up this example.