~ / Blog / How I Work With PostgreSQL in Go

How I Work With PostgreSQL in Go

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.