Skip to content

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
schema.sql
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 email 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
Terminal window
mkdir plainsql-demo && cd $_
go mod init example.com/blog
mkdir -p queries
# Now set the DATABASE_URL
export DATABASE_URL='postgres://demo:demopassword1@ep-mute-bar-ae5vh57t-pooler.c-2.us-east-2.aws.neon.tech/blog'

Create queries/posts_read.sql to list the ten most recent posts with their authors:

queries/posts_read.sql
-- plainsql: query ListRecentPosts returns many
SELECT
p.title,
coalesce(u.display_name, u.email) AS author,
p.created_at
FROM posts AS p
JOIN users AS u ON u.id = p.user_id
ORDER BY p.created_at DESC, p.id DESC
LIMIT $1;

At the root of your project, create a plainsql.yaml config file.

plainsql.yaml
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/dbgen
Terminal window
plainsql generate
go mod tidy

This will create 2 files:

  1. internal/dbgen/plainsql.go
  2. internal/dbgen/posts_read.sql.go

Now this is where it gets interesting.

main.go
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)
}
}
Terminal window
go mod tidy
go run main.go

Output:

CREATED (UTC) AUTHOR TITLE
2026-09-10 08:28 Milchick Your quarterly melon assessment
2026-09-10 08:28 Burt Please enjoy each painting equally
2026-09-10 08:28 Dylan Waffle party acceptance speech
2026-09-10 08:28 Irving The handbook did not cover this hallway
2026-09-09 08:39 Helly Rewriting my blog in Go
2026-09-09 08:39 Helly Hello, world

Replace 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".