Skip to content

Cloudflare D1 Quick Guide

Connection Setup

import (
    _ "github.com/peterheb/cfd1"    // D1 driver
    _ "github.com/mattn/go-sqlite3" // Local testing
)

db, err := database.New("cfd1", "https://api.cloudflare.com/client/v4/accounts/<ACCOUNT_ID>/d1/database/<DATABASE_ID>")

Connection pool settings: - SetMaxOpenConns(25) - Max concurrent connections - SetMaxIdleConns(5) - Idle pool size - SetConnMaxLifetime(5 * time.Minute) - Reuse time - SetConnMaxIdleTime(2 * time.Minute) - Idle timeout


Repository Pattern

Structure

type Repository struct {
    db *sql.DB
}

func NewRepository(db *sql.DB) *Repository {
    return &Repository{db: db}
}

CREATE

func (r *Repository) Create(ctx context.Context, req *CreateRequest) (*Model, error) {
    now := time.Now()
    result, err := r.db.ExecContext(ctx,
        `INSERT INTO table (col1, col2, created_at, updated_at) VALUES (?, ?, ?, ?)`,
        req.Col1, req.Col2, now, now)
    if err != nil {
        return nil, fmt.Errorf("failed to create: %w", err)
    }

    id, _ := result.LastInsertId()
    return r.GetByID(ctx, id)
}

READ

func (r *Repository) GetByID(ctx context.Context, id int64) (*Model, error) {
    rows, err := r.db.QueryContext(ctx,
        `SELECT id, col1, col2, created_at, updated_at FROM table WHERE id = ?`, id)
    if err != nil {
        return nil, fmt.Errorf("failed to query: %w", err)
    }
    defer rows.Close()

    if !rows.Next() {
        return nil, fmt.Errorf("not found")
    }

    model := &Model{}
    err = scanModel(rows, &model.ID, &model.Col1, &model.Col2, &model.CreatedAt, &model.UpdatedAt)
    return model, err
}

UPDATE

func (r *Repository) Update(ctx context.Context, id int64, req *UpdateRequest) (*Model, error) {
    result, err := r.db.ExecContext(ctx,
        `UPDATE table SET col1 = COALESCE(NULLIF(?, ''), col1), updated_at = ? WHERE id = ?`,
        req.Col1, time.Now(), id)
    if err != nil {
        return nil, fmt.Errorf("failed to update: %w", err)
    }

    if rows, _ := result.RowsAffected(); rows == 0 {
        return nil, fmt.Errorf("not found")
    }

    return r.GetByID(ctx, id)
}

DELETE

func (r *Repository) Delete(ctx context.Context, id int64) error {
    result, err := r.db.ExecContext(ctx, `DELETE FROM table WHERE id = ?`, id)
    if err != nil {
        return fmt.Errorf("failed to delete: %w", err)
    }

    if rows, _ := result.RowsAffected(); rows == 0 {
        return fmt.Errorf("not found")
    }
    return nil
}

Critical: D1 Type Handling

D1 returns different types than SQLite: - Integers → float64 (not int64) - Timestamps → string (not time.Time)

Scanner Function

func scanModel(scanner interface{Scan(dest ...interface{}) error},
               id *int64, name *string, createdAt, updatedAt *time.Time) error {
    var idFloat float64  // Must use float64 for D1
    var createdAtNT, updatedAtNT NullableTime  // Custom type for timestamps

    err := scanner.Scan(&idFloat, name, &createdAtNT, &updatedAtNT)
    if err != nil {
        return err
    }

    *id = int64(idFloat)  // Convert to int64

    if createdAtNT.Valid {
        *createdAt = createdAtNT.Time
    }
    if updatedAtNT.Valid {
        *updatedAt = updatedAtNT.Time
    }

    return nil
}

NullableTime Type

type NullableTime struct {
    Time  time.Time
    Valid bool
}

func (nt *NullableTime) Scan(value interface{}) error {
    if value == nil {
        nt.Valid = false
        return nil
    }

    nt.Valid = true

    switch v := value.(type) {
    case time.Time:
        nt.Time = v
    case string:
        formats := []string{
            time.RFC3339,
            "2006-01-02 15:04:05",
            "2006-01-02T15:04:05",
        }
        for _, format := range formats {
            if t, err := time.Parse(format, v); err == nil {
                nt.Time = t
                return nil
            }
        }
        return fmt.Errorf("failed to parse: %s", v)
    default:
        return fmt.Errorf("unsupported type: %T", value)
    }
    return nil
}

Migrations

File Structure

migrations/
└── 001_create_users_table.sql

Examples

Migration Template

-- Migration: Brief description
-- Created: YYYY-MM-DD

CREATE TABLE IF NOT EXISTS table_name (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    email TEXT NOT NULL UNIQUE,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_table_name ON table_name(name);

Foreign Keys

-- SET NULL on delete
FOREIGN KEY (room_type_id) REFERENCES room_types(id) ON DELETE SET NULL

-- CASCADE delete
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE

Junction Table (Many-to-Many)

CREATE TABLE IF NOT EXISTS user_rooms (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    user_id INTEGER NOT NULL,
    room_id INTEGER NOT NULL,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    FOREIGN KEY (room_id) REFERENCES rooms(id) ON DELETE CASCADE,
    UNIQUE(user_id, room_id)
);

CREATE INDEX IF NOT EXISTS idx_user_rooms_user_id ON user_rooms(user_id);
CREATE INDEX IF NOT EXISTS idx_user_rooms_room_id ON user_rooms(room_id);

Run Migrations

ctx := context.Background()
if err := db.MigrateFromFiles(ctx); err != nil {
    log.Fatal(err)
}

Best Practices Checklist and Tips

When writing repositories: - [ ] Scan integers as float64, convert to int64 - [ ] Use NullableTime for timestamps - [ ] Always defer rows.Close() - [ ] Check rows.Err() after iteration - [ ] Use COALESCE(NULLIF(?, ''), col) for partial updates - [ ] Set updated_at = NOW() on updates

When writing migrations: - [ ] Always use IF NOT EXISTS - [ ] Name files NNN_description.sql - [ ] Create indexes on foreign keys - [ ] Create indexes on WHERE/JOIN columns - [ ] Use UNIQUE constraints where appropriate