How I Work With PostgreSQL in Go

May 15, 2025

Watch the video version

In this article, I share my workflow for integrating PostgreSQL with my Go hobby project Vauban.

As I'm currently looking for a job as a backend developer, I've been keeping myself busy with side projects like this one.

I'll cover my database driver setup, migrations, repository pattern implementation, and seeding strategy.

I hope you'll find value in it.

1. Database Driver: pgx with Connection Pooling

I use jackc's pgx/v5 driver with connection pooling:

dbpool, err := pgxpool.New(context.Background(), env.GetVar("DATABASE_URL", "postgres://vauban:vauban@localhost/vauban?sslmode=disable"))
if err != nil {
    exit.WithErr(err)
}
defer dbpool.Close()

// Verify connection works
if err := dbpool.Ping(context.Background()); err != nil {
    exit.WithErr(err)
}

The connection pool handles lifecycle management, which helps when handling concurrent database access in web applications.

2. Migration Management with tern

For schema migrations, I use tern, also by jackc:

migration-up:
	@echo "โฌ†๏ธ  Running migrations..."
	TERN_MIGRATIONS="./database/migrations" tern -c ./tern.dev.conf migrate

migration-down:
	@echo "โฌ‡๏ธ  Rolling back migrations..."
	TERN_MIGRATIONS="./database/migrations" tern -c ./tern.dev.conf migrate --destination 0

migration-fresh: migration-down migration-up
	@echo "๐Ÿ”„ Database freshed!"

Each migration contains both up and down SQL:

CREATE TABLE users(
    id SERIAL PRIMARY KEY,
    first_name VARCHAR(50) NOT NULL,
    last_name VARCHAR(50) NOT NULL,
    email VARCHAR(255) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP
);

---- create above / drop below ----

DROP TABLE users;

3. Development Environment

My Makefile creates a consistent development environment:

.PHONY: setup db-up db-down migration-up migration-down migration-fresh seed clean

setup:
	@echo "๐Ÿ”ง Installing tern for database migrations..."
	go install github.com/jackc/tern/v2@latest
	@echo "๐Ÿ”ง Installing Reflex for hot reload"
	go install github.com/cespare/reflex@latest
	@echo "๐Ÿ“ฆ Installing dependencies..."
	go mod tidy

db-up:
	@echo "๐Ÿณ Starting database container..."
	docker compose -f ./compose.dev.yml up -d

db-down:
	@echo "๐Ÿ›‘ Stopping database container..."
	docker compose -f ./compose.dev.yml down

css:
	npx @tailwindcss/cli -i ./views/styles.css -o ./public/assets/styles.css --watch

dev:
	reflex --start-service -- sh -c 'go run ./cmd/http'

clean: db-down
	@echo "๐Ÿงน Development environment has been cleaned up!"

This allows for quick setup of the environment, isolated Docker containers for the database, and hot reloading during development.

4. Repository Pattern

I organize database access through repositories:

type UsersRepository interface {
	// Store creates a user record in the database, mutating fields generated by the DBMS.
	Store(ctx context.Context, user *models.User) error

	// GetByID retrieves a user by their ID.
	// Returns ErrUserNotFound if the user doesn't exist.
	GetByID(ctx context.Context, id int64) (*models.User, error)

	// GetByEmail retrieves a user by their email address.
	// Returns ErrUserNotFound if the user doesn't exist.
	GetByEmail(ctx context.Context, email string) (*models.User, error)
}

// ErrUserNotFound is returned when a user with the specified ID is not found.
var ErrUserNotFound = errors.New("user not found")

type usersRepository struct {
	dbpool *pgxpool.Pool
}

func NewUsersRepository(dbpool *pgxpool.Pool) UsersRepository {
	return &usersRepository{
		dbpool: dbpool,
	}
}

The repository pattern separates database access logic from business logic and provides a clean, testable interface.

5. Writing SQL Queries

I write plain SQL within the repository methods:

func (r *usersRepository) Store(ctx context.Context, user *models.User) error {
	user.Email = r.normalizeEmail(user.Email)

	query := `
		INSERT INTO users (first_name, last_name, email, password)
		VALUES ($1, $2, $3, $4)
		RETURNING id, created_at, updated_at
	`
	err := r.dbpool.QueryRow(
		ctx,
		query,
		user.FirstName,
		user.LastName,
		user.Email,
		user.Password,
	).Scan(&user.ID, &user.CreatedAt, &user.UpdatedAt)
	if err != nil {
		return fmt.Errorf("failed to store user: %w", err)
	}
	return nil
}

func (r *usersRepository) GetByID(ctx context.Context, id int64) (*models.User, error) {
	query := `
		SELECT id, first_name, last_name, email, password, created_at, updated_at
		FROM users
		WHERE id = $1
	`

	user := &models.User{}
	err := r.dbpool.QueryRow(
		ctx,
		query,
		id,
	).Scan(
		&user.ID,
		&user.FirstName,
		&user.LastName,
		&user.Email,
		&user.Password,
		&user.CreatedAt,
		&user.UpdatedAt,
	)

	if err != nil {
		if errors.Is(err, pgx.ErrNoRows) {
			return nil, ErrUserNotFound
		}
		return nil, fmt.Errorf("failed to get user by ID: %w", err)
	}

	return user, nil
}

I use parameterized queries to prevent SQL injection and wrap errors with context.

6. Database Seeding

For development and testing, I use seeders to populate the database:

// The seeder interface
type Seeder interface {
	Seed(ctx context.Context) error
}

// User seeder implementation
type UserSeeder struct {
	dbpool *pgxpool.Pool
}

func NewUserSeeder(dbpool *pgxpool.Pool) *UserSeeder {
	return &UserSeeder{
		dbpool: dbpool,
	}
}

// Seed creates the default set of users in the database.
func (s *UserSeeder) Seed(ctx context.Context) error {
	usersRepository := repositories.NewUsersRepository(s.dbpool)

	users := []*models.User{
		{Email: "socrates@athens.gr", Password: "i_know_that_i_know_nothing"},
		{Email: "plato@academy.gr", Password: "the_unexamined_life_is_not_worth_living"},
		// Additional users...
	}

	for _, user := range users {
		if err := usersRepository.Store(ctx, user); err != nil {
			return err
		}
	}

	return nil
}

This runs via a dedicated command, that I put in its own file:

// ./cmd/seed/main.go
func main() {
	ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
	defer cancel()

	dbpool, err := pgxpool.New(ctx, env.GetVar("DATABASE_URL", "postgres://panache:panache@localhost/panache?sslmode=disable"))
	if err != nil {
		exit.WithErr(err)
	}
	defer dbpool.Close()

	seeders := []seeders.Seeder{
		seeders.NewUserSeeder(dbpool),
	}

	for _, seeder := range seeders {
		if err := seeder.Seed(ctx); err != nil {
			exit.WithErr(err)
		}
	}
}

And then, I add a new command in my Makefile:

seed:
	@echo "๐ŸŒฑ Seeding database..."
	@go run ./cmd/seed
	@echo "โœจ Database successfully seeded!"

Conclusion

If you liked this article and want to see more of my work, check out my GitHub profile.

I'm currently looking for a developer position, so feel free to reach out if you think my approach to solving problems would be valuable to your team.

Join Discord Community