Managing databases from the terminal has always been a fragmented experience. You either use the native CLI tools (each with their own syntax and quirks) or switch to a GUI application. I wanted something different: a unified terminal interface for database management. That’s why I built pam.

The Problem

Working with multiple databases means juggling different clients:

  • psql for PostgreSQL
  • mysql for MySQL/MariaDB
  • sqlite3 for SQLite files

Each has different flags, query syntax, and output formats. It’s annoying to constantly switch mental models.

Why Go?

Go was the obvious choice for this project:

  1. Standard library - The database/sql package is excellent and driver-agnostic
  2. Cross-platform - Single binary for Linux, macOS, Windows
  3. Concurrency - Goroutines for managing multiple database connections
  4. TUI libraries - bubbletea for terminal UI

Architecture

Pam uses a simple architecture:

┌─────────────┐
│   TUI Layer │ (bubbletea)
└──────┬──────┘

┌──────▼──────┐
│    Core     │ (query execution, formatting)
└──────┬──────┘

┌──────▼──────┐
│  Drivers    │ (database/sql + pgx, mysql, sqlite)
└─────────────┘

The Core Package

The core is driver-agnostic. It takes a connection and SQL, returns formatted results:

func Execute(db *sql.DB, query string) (*Result, error) {
    rows, err := db.Query(query)
    if err != nil {
        return nil, err
    }
    defer rows.Close()

    // Get column names
    columns, _ := rows.Columns()
    result := &Result{Columns: columns}

    // Scan rows
    for rows.Next() {
        values := make([]interface{}, len(columns))
        valuePtrs := make([]interface{}, len(columns))

        for i := range columns {
            valuePtrs[i] = &values[i]
        }

        rows.Scan(valuePtrs...)
        result.Rows = append(result.Rows, values)
    }

    return result, nil
}

TUI with Bubbletea

Bubbletea uses the Elm architecture: Model, Update, View.

type model struct {
    db          *sql.DB
    query       string
    results     *Result
    errMsg      string
    tableHeight int
}

func (m model) Init() tea.Cmd {
    return nil
}

func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+c", "q":
            return m, tea.Quit
        case "enter":
            // Execute query
            return m, executeQuery(m.db, m.query)
        }
    case errMsg:
        m.errMsg = msg.Error()
        return m, nil
    case resultMsg:
        m.results = msg.result
        return m, nil
    }
    return m, nil
}

Connection Management

Pam stores connections in a config file at ~/.config/pam/connections.yaml:

- name: local_postgres
  driver: postgres
  dsn: postgres://user:pass@localhost:5432/db?sslmode=disable

- name: dev_mysql
  driver: mysql
  dsn: user:pass@tcp(localhost:3306)/dbname

The driver abstraction makes adding new databases trivial. Just import the driver and register it.

Challenges

NULL Values

SQL NULL doesn’t map cleanly to Go primitives. I used sql.NullString, sql.NullInt64, etc.:

for i, val := range values {
    if val == nil {
        row[i] = "NULL"
    } else {
        row[i] = fmt.Sprintf("%v", val)
    }
}

Large Result Sets

Fetching thousands of rows freezes the UI. Solution: pagination with lazy loading.

Table Rendering

Aligning columns is tricky with variable-width content. I implemented a simple column width calculator and used text/tabwriter for alignment.

What’s Next?

I’m working on:

  • Connection pooling
  • Query history
  • Export results to CSV/JSON
  • SSH tunnel support for remote databases

Pam is still early but already useful for my daily work. If you spend time in the terminal and work with databases, give it a try.

The code is on GitHub: https://github.com/eduardofuncao/pam