Skip to content

Connecting to a Database

TypeScript developers are used to choosing between Prisma, TypeORM, Drizzle, Knex, or Sequelize. Go has similar options — GORM, sqlc, Bun, Ent — but the stdlib database/sql package is a first-class option that is well worth knowing.

ConcernNode.js optionsGo options
Low-levelpg, mysql2 (raw drivers)database/sql + driver (e.g. lib/pq, pgx)
Query builderknexsquirrel, raw database/sql
Full ORMTypeORM, Drizzle, SequelizeGORM, ent, bun
Codegen from SQLsqlc (SQL → type-safe Go)

This lesson uses database/sql with the lib/pq driver for PostgreSQL — the most transferable baseline.

TypeScript
// Node.js — pg connection pool
import { Pool } from 'pg';
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
});
export default pool;
Go
// Go — database/sql + lib/pq
import (
"database/sql"
"log"
"os"
_ "github.com/lib/pq" // register driver via side-effect import
)
func NewDB() *sql.DB {
dsn := os.Getenv("DATABASE_URL")
db, err := sql.Open("postgres", dsn)
if err != nil {
log.Fatalf("sql.Open: %v", err)
}
if err := db.Ping(); err != nil {
log.Fatalf("db.Ping: %v", err)
}
db.SetMaxOpenConns(25)
db.SetMaxIdleConns(5)
return db
}

Rather than calling db directly from handlers, wrap database logic in a store struct. This mirrors the Repository pattern in NestJS and keeps handlers thin.

TypeScript
// NestJS — repository injection
@Injectable()
export class BooksRepository {
constructor(
@InjectRepository(Book)
private readonly repo: Repository<Book>,
) {}
async findById(id: string): Promise<Book | null> {
return this.repo.findOneBy({ id });
}
}
Go
// Go — store struct (manual DI)
type BookStore struct {
db *sql.DB
}
func NewBookStore(db *sql.DB) *BookStore {
return &BookStore{db: db}
}
func (s *BookStore) FindByID(ctx context.Context, id string) (Book, error) {
const q = `SELECT id, title, author, year FROM books WHERE id = $1`
var b Book
err := s.db.QueryRowContext(ctx, q, id).Scan(
&b.ID, &b.Title, &b.Author, &b.Year,
)
if errors.Is(err, sql.ErrNoRows) {
return Book{}, fmt.Errorf("FindByID: %w", ErrNotFound)
}
if err != nil {
return Book{}, fmt.Errorf("FindByID: %w", err)
}
return b, nil
}

Always use parameterized queries ($1, $2, …). Never interpolate values into SQL strings — that is a SQL injection vulnerability.

TypeScript
// Prisma insert
const book = await prisma.book.create({
data: { title, author, year },
});
Go
// database/sql insert — parameterized query
func (s *BookStore) Insert(ctx context.Context, req CreateBookRequest) (Book, error) {
const q = `
INSERT INTO books (title, author, year)
VALUES ($1, $2, $3)
RETURNING id, title, author, year
`
var b Book
err := s.db.QueryRowContext(ctx, q,
req.Title, req.Author, req.Year,
).Scan(&b.ID, &b.Title, &b.Author, &b.Year)
if err != nil {
return Book{}, fmt.Errorf("Insert: %w", err)
}
return b, nil
}

Wiring the store into a handler via main.go

Section titled “Wiring the store into a handler via main.go”
main.go
db := NewDB()
bookStore := store.NewBookStore(db)
bookHandler := handler.NewBookHandler(bookStore)
api := e.Group("/api")
api.GET("/books/:id", bookHandler.Get)
api.POST("/books", bookHandler.Create)

No DI container is needed — Go’s type system and constructor functions handle it explicitly.

  • sqlc — write plain SQL files, run sqlc generate, get type-safe Go functions. Zero reflection overhead. Best for teams that want SQL control.
  • GORM — ActiveRecord-style ORM; familiar if you come from Sequelize. Costs some query transparency.
  • pgx/pgxpool — a high-performance PostgreSQL driver (over database/sql). Recommended for production (see Go memory file). Same QueryRowContext / Scan pattern, better throughput.

Run this locally — it needs a live PostgreSQL database, the lib/pq driver module, and a network port.

Why must you use parameterized queries ($1, $2) instead of string interpolation in SQL?
What error does sql.QueryRowContext return when no rows match?
Why use the Context variant of database/sql methods (e.g. QueryContext)?