Quick start
Let's go from an empty folder to a saved row on PostgreSQL. You'll need Go 1.27 or later, and Docker for the database.
Set up
Make a module, then add ant and a PostgreSQL driver to it:
mkdir hello && cd hello
go mod init example.com/hello
go get github.com/skudra-go/ant github.com/jackc/pgx/v5/stdlib
ant sits on top of database/sql, so the driver is your choice. For PostgreSQL, that's pgx.
Then start a database:
docker run -d --name hello-db -p 127.0.0.1:5432:5432 \
-e POSTGRES_PASSWORD=hello postgres:17
Write the program
Here's all of main.go. Every line that touches ant is lit, and the rest is everyday Go.
main.go
package main
import (
"context"
"fmt"
"log"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/skudra-go/ant"
"github.com/skudra-go/ant/dialect/postgres"
)
type User struct {
ant.Base
Name string
Email string `ant:"unique"`
Age int
Posts []Post
}
type Post struct {
ant.Base
UserID int64
Title string
}
type AppDB struct {
ant.Context
Users ant.Table[User]
Posts ant.Table[Post]
}
var U = ant.Fields[User]()
func main() {
ctx := context.Background()
dsn := "postgres://postgres:hello@127.0.0.1:5432/postgres"
db, err := ant.Open[AppDB](ctx, postgres.Dialect, dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
// Just for this first run. Real projects use migrations.
if err := db.EnsureCreated(ctx); err != nil {
log.Fatal(err)
}
alice := &User{
Name: "Alice",
Email: "alice@example.com",
Age: 30,
Posts: []Post{{Title: "Hello"}, {Title: "Second post"}},
}
err = db.Users.Insert(ctx, alice)
if err != nil {
log.Fatal(err)
}
adults, err := db.Users.
Where(ant.Gt(&U.Age, 18)).
Include(&U.Posts).
All(ctx)
if err != nil {
log.Fatal(err)
}
fmt.Println(adults[0].Name, "has", len(adults[0].Posts), "posts")
adults[0].Age = 31
err = db.SaveChanges(ctx)
if err != nil {
log.Fatal(err)
}
stored, err := db.Users.NoTracking().Find(ctx, alice.ID)
if err != nil {
log.Fatal(err)
}
fmt.Println(stored.Name, "is now", stored.Age)
// Clean up, so you can run this again.
if err := db.EnsureDeleted(ctx); err != nil {
log.Fatal(err)
}
}
Run it
go mod tidy
go run .
Alice has 2 posts
Alice is now 31
What just happened
UserandPostare ordinary structs.ant.Basegives each oneID,CreatedAt, andUpdatedAt. ThePostsfield is a relation, and ant finds it throughPost.UserIDby name.AppDBis your database, as a Go type.ant.Openfills in every table on it.Uis a field probe.&U.Agestands for theagecolumn, and the compiler checks whatever you compare it with.Insertsaved the whole graph. Alice went in first, then her posts withUserIDalready filled in, and every new key landed back on the structs.Includeran a single extra query to fetch all the posts at once.SaveChangeswrote one column. ant compared Alice with what it had loaded, saw onlyAgehad changed, and sent just this:
UPDATE "users" SET "age" = $1, "updated_at" = $2 WHERE "users"."id" = $3
NoTracking().Find reads straight from the database, past anything ant has in memory, which is how you can see that the change really landed.
Next
- Concepts: the handful of ideas behind everything above.
- Migrations: swap
EnsureCreatedfor SQL files you review. - Example projects: a blog API, an audit service, and a ledger.