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:
psqlfor PostgreSQLmysqlfor MySQL/MariaDBsqlite3for 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:
- Standard library - The
database/sqlpackage is excellent and driver-agnostic - Cross-platform - Single binary for Linux, macOS, Windows
- Concurrency - Goroutines for managing multiple database connections
- 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