Quickstart
To spare you the trouble of setting up a local database, applying migrations, etc. there’s a hosted (read-only) public database.
This way you can write some queries, view the generated Go code, and actually execute them!
If you don’t like follow along style tutorials, no worries, check out the quickstart repository for the final code.
The demo database has two tables: users and posts, populated with sample data.
View the database schema
CREATE TABLE users ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, email text NOT NULL UNIQUE, display_name text, created_at timestamptz NOT NULL DEFAULT now());
CREATE TABLE posts ( id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, title text NOT NULL, body text, created_at timestamptz NOT NULL DEFAULT now());
CREATE INDEX posts_user_id_idx ON posts (user_id);View the data
The tables omit created_at.
| id | display_name | |
|---|---|---|
| 1 | [email protected] |
Helly |
| 2 | [email protected] |
Mark |
| 3 | [email protected] |
Irving |
| 4 | [email protected] |
Dylan |
| 5 | [email protected] |
Burt |
| 6 | [email protected] |
Milchick |
| id | user_id | title | body |
|---|---|---|---|
| 1 | 1 | Hello, world | NULL |
| 2 | 1 | Rewriting my blog in Go | This time it will be simple. |
| 3 | 3 | The handbook did not cover this hallway | Seven left turns. Same painting. I have filed a report with the painting. |
| 4 | 4 | Waffle party acceptance speech | I would like to thank the numbers for being scary and the waffles for being waffles. |
| 5 | 5 | Please enjoy each painting equally | The angry painting has noticed the difference. |
| 6 | 6 | Your quarterly melon assessment | Your department has earned six melon cubes. Please appoint a cube representative. |
Create a project
Section titled “Create a project”mkdir plainsql-demo && cd $_go mod init example.com/blogmkdir -p queries
# Now set the DATABASE_URLexport DATABASE_URL='postgres://demo:demopassword1@ep-mute-bar-ae5vh57t-pooler.c-2.us-east-2.aws.neon.tech/blog'Write a query
Section titled “Write a query”Create queries/posts_read.sql to list the ten most recent posts with their authors:
-- plainsql: query ListRecentPosts returns manySELECT p.title, coalesce(u.display_name, u.email) AS author, p.created_atFROM posts AS pJOIN users AS u ON u.id = p.user_idORDER BY p.created_at DESC, p.id DESCLIMIT $1;Configure and generate
Section titled “Configure and generate”At the root of your project, create a plainsql.yaml config file.
projects: # A configuration can contain multiple named projects. blog: database: # Read connection string from environment. dsn: ${DATABASE_URL} queries: # Find .sql files recursively. Paths are relative to this configuration file. - dir: queries # Checks these queries are safe for a read-only connection. mode: read generate: go: package: dbgen # Generation will replace this directory. Keep application code elsewhere. dir: internal/dbgenplainsql generatego mod tidyThis will create 2 files:
internal/dbgen/plainsql.gointernal/dbgen/posts_read.sql.go
Call it from Go
Section titled “Call it from Go”Now this is where it gets interesting.
package main
import ( "context" "fmt" "log" "os" "text/tabwriter"
"github.com/jackc/pgx/v5/pgxpool" "example.com/blog/internal/dbgen")
func main() { ctx := context.Background() pool, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL")) if err != nil { log.Fatal(err) } defer pool.Close()
// New wraps the pool. Every generated query is a method on the result. queries := dbgen.New(pool)
// ListRecentPosts is the method generated from queries/posts_read.sql. // The 10 fills the $1 placeholder in LIMIT $1. Each post has typed Title, // Author, and CreatedAt fields, so there is no manual row scanning. posts, err := queries.ListRecentPosts(ctx, 10) if err != nil { log.Fatal(err) } w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0) fmt.Fprintln(w, "CREATED (UTC)\tAUTHOR\tTITLE") for _, post := range posts { fmt.Fprintf(w, "%s\t%s\t%s\n", post.CreatedAt.UTC().Format("2006-01-02 15:04"), post.Author, post.Title) } if err := w.Flush(); err != nil { log.Fatal(err) }}go mod tidygo run main.goOutput:
CREATED (UTC) AUTHOR TITLE2026-09-10 08:28 Milchick Your quarterly melon assessment2026-09-10 08:28 Burt Please enjoy each painting equally2026-09-10 08:28 Dylan Waffle party acceptance speech2026-09-10 08:28 Irving The handbook did not cover this hallway2026-09-09 08:39 Helly Rewriting my blog in Go2026-09-09 08:39 Helly Hello, worldReplace p.title with a column that does not exist:
p.title,p.titl,Run plainsql check:
queries/posts_read.sql:3:5: column p.titl does not exist hint: Perhaps you meant to reference the column "p.title".