When it comes to building high-performance backends, the combination of Go (Golang) and PostgreSQL is a powerhouse. In my experience, this stack provides the perfect balance between the type-safety and concurrency of Go and the reliability of a relational database. If you are building a REST API with Go and PostgreSQL, the biggest challenge isn’t the syntax—it’s the architecture.
Too many developers throw everything into a single main.go file. While that works for a ‘Hello World’, it falls apart the moment you add authentication or complex business logic. That’s why I always recommend following golang project structure best practices to keep the codebase maintainable.
Prerequisites
- Go 1.21+ installed on your machine.
- PostgreSQL installed and running.
- A basic understanding of HTTP methods (GET, POST, PUT, DELETE).
- Postman or Insomnia for testing endpoints.
Step 1: Setting Up the Database
Before writing Go code, we need a place to store our data. Let’s create a simple ‘Books’ API. Run the following SQL in your PostgreSQL terminal:
CREATE TABLE books (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
author TEXT NOT NULL,
published_year INTEGER
);
Step 2: Project Initialization and Dependencies
Initialize your module and install the necessary drivers. I prefer pgx over the standard lib/pq because it is more actively maintained and offers better performance.
go mod init go-postgres-api
go get github.com/jackc/pgx/v5
go get github.com/gorilla/mux
Step 3: Establishing the Database Connection
I’ve found that creating a database singleton or passing a connection pool through a repository pattern is the cleanest way to handle state in Go. Here is a production-ready connection setup:
package main
import (
"context"
"fmt"
"os"
"github.com/jackc/pgx/v5/pgxpool)
)
func ConnectDB() *pgxpool.Pool {
connString := "postgres://username:password@localhost:5432/bookstore"
config, err := pgxpool.ParseConfig(connString)
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to parse config: %v\n", err)
os.Exit(1)
}
pool, err := pgxpool.NewWithConfig(context.Background(), config)
if err != nil {
fmt.Fprintf(os.Stderr, "Unable to create connection pool: %v\n", err)
os.Exit(1)
}
return pool
}
Step 4: Creating the API Handlers
Now, let’s build the logic to fetch and create books. To keep things clean, I separate the data models from the HTTP logic. While we are using raw SQL here for maximum performance, if you find yourself writing too much boilerplate, you might want to explore a GORM vs Ent comparison to see if an ORM fits your needs better.
type Book struct {
ID int `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
PublishedYear int `json:"published_year"`
}
func GetBooks(db *pgxpool.Pool) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
rows, err := db.Query(context.Background(), "SELECT id, title, author, published_year FROM books")
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
defer rows.Close()
var books []Book
for rows.Next() {
var b Book
rows.Scan(&b.ID, &b.Title, &b.Author, &b.PublishedYear)
books = append(books, b)
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(books)
}
}
As shown in the image below, you can see how the request flows from the router directly into the handler, which then queries the PostgreSQL pool before returning a JSON response.
Step 5: Wiring the Router and Server
Finally, let’s put everything together in main.go. I’m using gorilla/mux for routing because of its excellent support for variables in URLs.
func main() {
db := ConnectDB()
defer db.Close()
r := mux.NewRouter()
r.HandleFunc("/books", GetBooks(db)).Methods("GET")
// Add other routes here
fmt.Println("Server starting on :8080")
http.ListenAndServe(":8080", r)
}
Pro Tips for Production
- Use Environment Variables: Never hardcode your DB credentials. Use
os.Getenv("DATABASE_URL"). - Connection Pooling: Always use
pgxpoolinstead of a single connection to avoid bottlenecks during traffic spikes. - Graceful Shutdown: Implement a listener for
SIGINTandSIGTERMto close your DB pool before the app exits. - Middleware: Add a logging middleware to track request latency and 400/500 error rates.
Troubleshooting Common Issues
“pq: password authentication failed”
Double-check your pg_hba.conf file or verify that your connection string format is postgres://user:pass@host:port/dbname.
“connection refused”
Ensure PostgreSQL is actually running and listening on port 5432. On macOS, try brew services list; on Linux, systemctl status postgresql.
What’s Next?
Now that you have a basic API running, the next step is to add security. I recommend implementing JWT (JSON Web Tokens) for authentication and adding a validation layer using go-playground/validator to ensure the data entering your PostgreSQL database is clean.