Build a REST API With Chi and Ent From Scratch Step by Step
Build a task API in Go with chi and Ent on PostgreSQL: schema, routes, error mapping, partial updates and tests that count statements, every status code run

A REST API over a database is the project every Go developer builds first, and the interesting question is not whether it works but whether it keeps working as it grows: whether errors are handled in one place, whether lists cost a constant number of queries, whether a partial update leaves the other fields alone. This article builds a small task API with chi as the router and Ent as the data layer, runs it through a test suite on PostgreSQL 18.6, and shows the code that makes each of those properties hold. It is the Ent counterpart of the GORM blog backend.
Tested with Go 1.25.6, Ent v0.14.5, chi v5.2.1, pgx v5.11.0, PostgreSQL 18.6 in Docker, macOS on Apple Silicon (arm64)
It assumes the basics from the Ent tutorial. The router follows choosing a router, and the testing approach is the schema per test method from Ent testing.
What the API does
| Route | Result |
|---|---|
POST /projects | Create a project with a unique name. 201, 409 if the name exists, 422 if empty, 400 for bad JSON |
GET /projects/{id} | The project with its tasks. 404 if absent |
POST /projects/{id}/tasks | Add a task. 201, 422 for a bad priority |
GET /projects/{id}/tasks?done= | List tasks, highest priority first, optionally filtered |
PATCH /tasks/{id} | Change only the fields present in the body |
DELETE /tasks/{id} | 204, or 404 |
Step 1: the schema
A project has a unique name and many tasks. A task belongs to a project and carries a title, a done flag and a priority from 1 to 5:
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
)
type Project struct{ ent.Schema }
func (Project) Fields() []ent.Field {
return []ent.Field{field.String("name").NotEmpty().Unique()}
}
func (Project) Edges() []ent.Edge { return []ent.Edge{edge.To("tasks", Task.Type)} }package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
)
type Task struct{ ent.Schema }
func (Task) Fields() []ent.Field {
return []ent.Field{
field.Int("project_id"),
field.String("title").NotEmpty(),
field.Bool("done").Default(false),
field.Int("priority").Default(3).Range(1, 5),
}
}
func (Task) Edges() []ent.Edge {
return []ent.Edge{edge.From("project", Project.Type).Ref("tasks").Unique().Required().Field("project_id")}
}Three decisions in these files carry through the API.
- The foreign key is a named field.
field.Int("project_id")plus.Field("project_id")on the edge makesproject_ida first class column. Handlers can writetask.ProjectID(id)in a predicate andSetProjectID(id)in a create, with no entity loaded. Without it, Ent hides the column and every create needs the parent entity. - Validation lives in the schema.
NotEmpty()on the name and title andRange(1, 5)on the priority mean the API needs no validation code of its own for these rules. Ent checks them before the insert and the error maps to 422. - Defaults live in the schema.
donedefaults to false andpriorityto 3, so a create that omits them gets them.
Generate the client with the directive:
package ent
//go:generate go run -mod=mod entgo.io/ent/cmd/ent generate ./schemaStep 2: the handlers and the router
package main
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"time"
"example.test/lab/ent"
"example.test/lab/ent/project"
"example.test/lab/ent/task"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
)
type API struct{ client *ent.Client }
func (a *API) Routes() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID, middleware.Recoverer, middleware.Timeout(5*time.Second))
r.Route("/projects", func(r chi.Router) {
r.Post("/", a.createProject)
r.Get("/{id}", a.getProject)
r.Get("/{id}/tasks", a.listTasks)
r.Post("/{id}/tasks", a.createTask)
})
r.Route("/tasks/{id}", func(r chi.Router) {
r.Patch("/", a.updateTask)
r.Delete("/", a.deleteTask)
})
return r
}
func write(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(v)
}
// fail maps Ent errors to HTTP statuses in one place.
func fail(w http.ResponseWriter, err error) {
switch {
case ent.IsNotFound(err):
write(w, http.StatusNotFound, map[string]string{"error": "not found"})
case ent.IsConstraintError(err):
write(w, http.StatusConflict, map[string]string{"error": "already exists or still referenced"})
case ent.IsValidationError(err):
write(w, http.StatusUnprocessableEntity, map[string]string{"error": err.Error()})
default:
write(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
}
}
func id(r *http.Request) int {
n, _ := strconv.Atoi(chi.URLParam(r, "id"))
return n
}
type projectJSON struct {
ID int `json:"id"`
Name string `json:"name"`
Tasks []taskJSON `json:"tasks,omitempty"`
}
type taskJSON struct {
ID int `json:"id"`
Title string `json:"title"`
Done bool `json:"done"`
Priority int `json:"priority"`
}
func toTask(t *ent.Task) taskJSON {
return taskJSON{ID: t.ID, Title: t.Title, Done: t.Done, Priority: t.Priority}
}
func (a *API) createProject(w http.ResponseWriter, r *http.Request) {
var in struct{ Name string }
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
write(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
p, err := a.client.Project.Create().SetName(in.Name).Save(r.Context())
if err != nil {
fail(w, err)
return
}
write(w, http.StatusCreated, projectJSON{ID: p.ID, Name: p.Name})
}
func (a *API) getProject(w http.ResponseWriter, r *http.Request) {
p, err := a.client.Project.Query().Where(project.ID(id(r))).WithTasks(func(q *ent.TaskQuery) { q.Order(ent.Asc(task.FieldID)) }).Only(r.Context())
if err != nil {
fail(w, err)
return
}
out := projectJSON{ID: p.ID, Name: p.Name}
for _, t := range p.Edges.Tasks {
out.Tasks = append(out.Tasks, toTask(t))
}
write(w, http.StatusOK, out)
}
func (a *API) listTasks(w http.ResponseWriter, r *http.Request) {
q := a.client.Task.Query().Where(task.ProjectID(id(r))).Order(ent.Desc(task.FieldPriority), ent.Asc(task.FieldID))
switch r.URL.Query().Get("done") {
case "true":
q = q.Where(task.Done(true))
case "false":
q = q.Where(task.Done(false))
}
ts, err := q.All(r.Context())
if err != nil {
fail(w, err)
return
}
out := make([]taskJSON, 0, len(ts))
for _, t := range ts {
out = append(out, toTask(t))
}
write(w, http.StatusOK, out)
}
func (a *API) createTask(w http.ResponseWriter, r *http.Request) {
var in struct {
Title string
Priority *int
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
write(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
t, err := a.client.Task.Create().SetProjectID(id(r)).SetTitle(in.Title).SetNillablePriority(in.Priority).Save(r.Context())
if err != nil {
fail(w, err)
return
}
write(w, http.StatusCreated, toTask(t))
}
// updateTask applies only the fields present in the JSON body.
func (a *API) updateTask(w http.ResponseWriter, r *http.Request) {
var in struct {
Title *string
Done *bool
Priority *int
}
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
write(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
t, err := a.client.Task.UpdateOneID(id(r)).
SetNillableTitle(in.Title).SetNillableDone(in.Done).SetNillablePriority(in.Priority).
Save(r.Context())
if err != nil {
fail(w, err)
return
}
write(w, http.StatusOK, toTask(t))
}
func (a *API) deleteTask(w http.ResponseWriter, r *http.Request) {
if err := a.client.Task.DeleteOneID(id(r)).Exec(r.Context()); err != nil {
fail(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
var _ = errors.NewThe structure is worth reading in order.
Routing. chi's Route groups paths, and {id} is read with chi.URLParam. The router has three middlewares: RequestID (a correlation id per request), Recoverer (a panic becomes a 500 instead of a dead connection) and Timeout (the request context is cancelled after five seconds). Because every handler passes r.Context() to Ent, that timeout cancels in flight queries (context cancellation shows the effect on the database).
Error mapping. fail is the whole error policy. ent.IsNotFound becomes 404, ent.IsConstraintError 409, ent.IsValidationError 422 with the validator's message, and anything else a generic 500 whose cause stays in the logs. The handlers call fail(w, err) and return. This is the payoff of Ent's typed errors: no string matching and no driver specific codes (the same idea for GORM needs more work).
Reads. getProject loads the project with WithTasks(...) and an ordering callback. That is two statements however many tasks there are. listTasks composes predicates conditionally: it starts with the project filter, and the done query parameter adds a predicate only when it is true or false. A missing or unknown value adds nothing, so there is no way to inject a condition.
Writes. createTask passes the optional priority through SetNillablePriority, which sets the field only when the pointer is non nil and otherwise leaves the schema default. updateTask decodes into pointer fields and applies only those that are present.
Step 3: the entry point
package main
import (
"context"
"database/sql"
"log"
"net/http"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"example.test/lab/ent"
_ "github.com/jackc/pgx/v5/stdlib"
)
func main() {
db, err := sql.Open("pgx", "postgres://postgres:[email protected]:55432/lab?sslmode=disable")
if err != nil {
log.Fatal(err)
}
client := ent.NewClient(ent.Driver(entsql.OpenDB(dialect.Postgres, db)))
if err := client.Schema.Create(context.Background()); err != nil {
log.Fatal(err)
}
log.Fatal(http.ListenAndServe(":8080", (&API{client}).Routes()))
}main opens a connection, creates the tables with Schema.Create (fine for the lab; use versioned migrations in production, see Ent with Atlas) and serves the router. The tests do not use it, so nothing in the suite binds a port.
Step 4: the tests
Each test gets its own PostgreSQL schema, a statement counter wired to Ent's debug log, and a handler. The helper call sends a request through httptest and decodes the JSON.
package main
import (
"bytes"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"entgo.io/ent/dialect"
entsql "entgo.io/ent/dialect/sql"
"example.test/lab/ent"
_ "github.com/jackc/pgx/v5/stdlib"
)
var schemas atomic.Int64
// newAPI gives each test its own schema, a statement counter and a handler.
func newAPI(t *testing.T) (http.Handler, *atomic.Int64) {
t.Helper()
base := "postgres://postgres:[email protected]:55432/lab?sslmode=disable"
admin, err := sql.Open("pgx", base)
if err != nil {
t.Fatal(err)
}
schema := fmt.Sprintf("l0752_t%d", schemas.Add(1))
admin.Exec("drop schema if exists " + schema + " cascade")
admin.Exec("create schema " + schema)
t.Cleanup(func() { admin.Exec("drop schema " + schema + " cascade"); admin.Close() })
db, err := sql.Open("pgx", base+"&search_path="+schema)
if err != nil {
t.Fatal(err)
}
var stmts atomic.Int64
client := ent.NewClient(
ent.Driver(entsql.OpenDB(dialect.Postgres, db)),
ent.Log(func(args ...any) {
if strings.Contains(fmt.Sprint(args...), "Query:") {
stmts.Add(1)
}
}),
ent.Debug(),
)
t.Cleanup(func() { client.Close() })
if err := client.Schema.Create(t.Context()); err != nil {
t.Fatal(err)
}
stmts.Store(0)
return (&API{client}).Routes(), &stmts
}
func call(t *testing.T, h http.Handler, method, path, body string) (int, map[string]any, []any) {
t.Helper()
req := httptest.NewRequest(method, path, bytes.NewBufferString(body))
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
var obj map[string]any
var arr []any
if json.Unmarshal(rec.Body.Bytes(), &obj) != nil {
json.Unmarshal(rec.Body.Bytes(), &arr)
}
return rec.Code, obj, arr
}
func TestProjectsAndConflicts(t *testing.T) {
h, _ := newAPI(t)
code, p, _ := call(t, h, "POST", "/projects", `{"name":"Launch"}`)
t.Logf("create project: %d id=%v", code, p["id"])
code, _, _ = call(t, h, "POST", "/projects", `{"name":"Launch"}`)
t.Logf("duplicate name: %d", code)
code, e, _ := call(t, h, "POST", "/projects", `{"name":""}`)
t.Logf("empty name: %d %v", code, e["error"])
code, _, _ = call(t, h, "POST", "/projects", `not json`)
t.Logf("bad JSON: %d", code)
code, _, _ = call(t, h, "GET", "/projects/999", "")
t.Logf("missing project: %d", code)
}
func TestTasksLifecycle(t *testing.T) {
h, _ := newAPI(t)
call(t, h, "POST", "/projects", `{"name":"Launch"}`)
code, tk, _ := call(t, h, "POST", "/projects/1/tasks", `{"title":"Write docs"}`)
t.Logf("create task: %d default priority=%v done=%v", code, tk["priority"], tk["done"])
call(t, h, "POST", "/projects/1/tasks", `{"title":"Ship","priority":5}`)
code, e, _ := call(t, h, "POST", "/projects/1/tasks", `{"title":"Too urgent","priority":9}`)
t.Logf("priority 9: %d %v", code, e["error"])
code, _, _ = call(t, h, "POST", "/projects/42/tasks", `{"title":"Orphan"}`)
t.Logf("task for a missing project: %d", code)
code, tk, _ = call(t, h, "PATCH", "/tasks/1", `{"done":true}`)
t.Logf("patch done only: %d title=%q done=%v priority=%v", code, tk["title"], tk["done"], tk["priority"])
code, _, _ = call(t, h, "PATCH", "/tasks/999", `{"done":true}`)
t.Logf("patch missing: %d", code)
_, _, open := call(t, h, "GET", "/projects/1/tasks?done=false", "")
_, _, all := call(t, h, "GET", "/projects/1/tasks", "")
t.Logf("tasks: %d open of %d, highest priority first: %v", len(open), len(all), all[0].(map[string]any)["title"])
code, _, _ = call(t, h, "DELETE", "/tasks/1", "")
t.Logf("delete: %d", code)
code, _, _ = call(t, h, "DELETE", "/tasks/1", "")
t.Logf("delete again: %d", code)
}
func TestProjectReadIsTwoStatements(t *testing.T) {
h, stmts := newAPI(t)
call(t, h, "POST", "/projects", `{"name":"Big"}`)
for i := 0; i < 30; i++ {
call(t, h, "POST", "/projects/1/tasks", fmt.Sprintf(`{"title":"task %d"}`, i))
}
stmts.Store(0)
code, p, _ := call(t, h, "GET", "/projects/1", "")
n := len(p["tasks"].([]any))
t.Logf("GET /projects/1: %d, %d tasks, %d statements", code, n, stmts.Load())
if stmts.Load() != 2 {
t.Fatalf("expected 2 statements, got %d", stmts.Load())
}
}=== RUN TestProjectsAndConflicts
api_test.go:72: create project: 201 id=1
api_test.go:74: duplicate name: 409
api_test.go:76: empty name: 422 ent: validator failed for field "Project.name": value is less than the required length
api_test.go:78: bad JSON: 400
api_test.go:80: missing project: 404
--- PASS: TestProjectsAndConflicts (0.11s)
=== RUN TestTasksLifecycle
api_test.go:87: create task: 201 default priority=3 done=false
api_test.go:90: priority 9: 422 ent: validator failed for field "Task.priority": value out of range
api_test.go:92: task for a missing project: 409
api_test.go:95: patch done only: 200 title="Write docs" done=true priority=3
api_test.go:97: patch missing: 404
api_test.go:101: tasks: 1 open of 2, highest priority first: Ship
api_test.go:104: delete: 204
api_test.go:106: delete again: 404
--- PASS: TestTasksLifecycle (0.13s)
=== RUN TestProjectReadIsTwoStatements
api_test.go:118: GET /projects/1: 200, 30 tasks, 2 statements
--- PASS: TestProjectReadIsTwoStatements (0.09s)
PASS
ok example.test/lab 1.789sset -e
GOFLAGS=-mod=mod go generate ./ent
go mod tidy > /dev/null 2>&1
go test -count=1 -race -v . 2>&1 | grep -vE "^(=== |--- PASS: Test.*/)" api_test.go:72: create project: 201 id=1
api_test.go:74: duplicate name: 409
api_test.go:76: empty name: 422 ent: validator failed for field "Project.name": value is less than the required length
api_test.go:78: bad JSON: 400
api_test.go:80: missing project: 404
--- PASS: TestProjectsAndConflicts (0.10s)
api_test.go:87: create task: 201 default priority=3 done=false
api_test.go:90: priority 9: 422 ent: validator failed for field "Task.priority": value out of range
api_test.go:92: task for a missing project: 409
api_test.go:95: patch done only: 200 title="Write docs" done=true priority=3
api_test.go:97: patch missing: 404
api_test.go:101: tasks: 1 open of 2, highest priority first: Ship
api_test.go:104: delete: 204
api_test.go:106: delete again: 404
--- PASS: TestTasksLifecycle (0.07s)
api_test.go:118: GET /projects/1: 200, 30 tasks, 2 statements
--- PASS: TestProjectReadIsTwoStatements (0.09s)
PASS
ok example.test/lab 1.721sWhat the run proved
Projects and conflicts. Creating Launch returned 201. Creating it again returned 409: ent.IsConstraintError on the unique index. An empty name returned 422 with validator failed for field "Project.name": value is less than the required length. Malformed JSON returned 400, which the handler produces itself before touching Ent, and an unknown id returned 404.
Tasks. A task created without a priority came back with priority 3 and done false: the schema defaults. A priority of 9 returned 422 with value out of range for Task.priority. A task for project 42, which does not exist, returned 409: the foreign key rejected the insert and IsConstraintError covers every constraint, including this one. That is the least precise mapping in the API. A client that posts to a missing project should get a 404. To fix it, look the project up first (Project.Query().Where(project.ID(id)).Exist) and return 404 when it is absent, or check the constraint name. The lab leaves it as a visible example of the cost of one catch-all rule.
Partial update. PATCH /tasks/1 with {"done":true} returned the task with the original title (Write docs) and priority (3) and done true. Only the fields present in the body were set. A PATCH to a task that does not exist returned 404, because UpdateOneID returns a not found error.
Listing. After the tasks were created, the open tasks (done=false) numbered 1 of 2, and the unfiltered list started with Ship, the priority 5 task, which shows the ordering.
Deleting. DELETE /tasks/1 returned 204 and the same call again returned 404.
Statement count. The last test creates a project with 30 tasks, resets the counter and reads it with one GET. It logged 200, 30 tasks, 2 statements and asserts the count is 2. This test is the guard rail. If someone replaces WithTasks with a loop that loads tasks per project, or adds a lazily loaded edge in the JSON conversion, the number changes and the build fails.
Trying it by hand
With the lab database running, start the server with go run . and use curl. The responses below are the shapes the tests assert:
curl -s -X POST localhost:8080/projects -d '{"name":"Launch"}'
curl -s -X POST localhost:8080/projects/1/tasks -d '{"title":"Write docs"}'
curl -s -X POST localhost:8080/projects/1/tasks -d '{"title":"Ship","priority":5}'
curl -s -X PATCH localhost:8080/tasks/1 -d '{"done":true}'
curl -s 'localhost:8080/projects/1/tasks?done=false'
curl -s localhost:8080/projects/1The last call returns the project with its tasks embedded, in id order. Add -i to see the status line and the X-Request-Id header chi's RequestID middleware sets, which is the value to put in your logs.
Why each file is shaped the way it is
api.go holds the whole HTTP surface. Routes, handlers, the JSON types and the error mapping are in one file because they change together. When the API grows, split by resource (projects.go, tasks.go) and keep fail and write in a small shared file. Resist adding a layer of services between the handlers and Ent until a handler needs to do more than one thing: for a CRUD API, a handler that calls the client directly is the clearest code, and a service layer that forwards calls adds files without adding behaviour.
The JSON types are separate from the entities. taskJSON has four fields, and ent.Task has five (it includes project_id). The conversion function toTask is the only place that decides what the API exposes. If a column is added to the schema tomorrow, the API does not change until toTask does. This costs fifteen lines and removes a whole class of accidental disclosure.
IDs are integers in the path. id(r) converts with strconv.Atoi and ignores the error, so /projects/abc becomes id 0, which no row has, and the response is a 404. That is acceptable for the lab. A stricter API returns 400 for an id that does not parse.
The decode step is lenient. json.NewDecoder(r.Body).Decode accepts unknown fields and trailing data. Call dec.DisallowUnknownFields() if you prefer an error when a client sends a field you do not handle, and cap the body with http.MaxBytesReader so a client cannot send a gigabyte.
Concurrency and consistency
Three behaviours deserve a thought even in a small API.
Duplicate creates race. Two simultaneous POST /projects with the same name both pass any check you might write first, and the unique index stops the second. The handler does not check first. It lets the database decide and maps the constraint error to 409, which is correct under concurrency. Check then insert is the pattern to avoid.
Updates race too. Two PATCH requests to the same task each apply their own fields, so the last writer wins on any field both set. For fields where lost updates matter, add a version column and require it in the request, the optimistic locking pattern from the GORM locking article, which maps directly to Where(task.Version(v)) in an Ent update.
Deletes are not cascades. Deleting a project that has tasks would fail on the foreign key (409), and the lab has no project delete route for that reason. Decide the policy (cascade, soft delete or refuse) before adding it.
What to add before production
- Authentication and authorisation. Middleware to identify the caller, then Ent privacy rules so reads and writes are scoped to what the caller may touch.
- Response types. The JSON structs here already separate the API from the entities. Keep them separate when the schema grows.
- Pagination.
listTasksreturns every task. Add a limit and a keyset cursor (GORM pagination explains the trade). - Versioned migrations in place of
Schema.Create. - Structured logging and metrics keyed by the request id and route pattern.
- A body size limit with
http.MaxBytesReader, and stricter JSON decoding (DisallowUnknownFields). - Graceful shutdown so in flight requests finish (graceful HTTP shutdown).
Operating it
A few settings turn the lab into something you can run. Set the database pool explicitly on the *sql.DB before wrapping it for Ent (SetMaxOpenConns, SetMaxIdleConns, SetConnMaxLifetime), sized against the database's connection limit and the number of instances, as measured in the GORM pool article; Ent uses whatever pool you give it. Put the server behind explicit timeouts (ReadHeaderTimeout, ReadTimeout, WriteTimeout) on http.Server instead of the bare ListenAndServe in the lab, because the default server has none and a slow client can hold a connection open indefinitely.
For visibility, log one line per request with the request id, method, route pattern (not the raw path, so metrics do not explode in cardinality), status and duration. chi's RequestID gives the id, and the route pattern is available from the router context after the handler has run. Count statements per request in development with the same ent.Log hook the tests use, and watch the number for each route as the API grows. A route whose statement count depends on the size of the data is the next bug to fix.
Finally, deploy migrations separately from the server. The Schema.Create call in main is convenient for a lab. In production a versioned migration runs once, before the new code starts, and the server only connects.
Extending it
A new resource follows the same five steps: schema, generate, handlers, error mapping through fail, and tests that cover each status code and the statement count of every read. The code in fail and the habit of counting statements are what keep the API honest as it grows.
Checklist
- Name the foreign key field so handlers can filter and set it by id.
- Put validation and defaults in the schema, and map
IsValidationErrorto 422. - Map Ent errors to statuses in a single function.
- Use pointer fields and
SetNillable...for partial updates. - Pass
r.Context()to every Ent call, and add a timeout middleware. - Assert statement counts for read endpoints.
- Test every status code, including the awkward ones like the 409 above.
The router is documented in the chi repository, and the Ent features used in the Ent documentation.


