Engineers constantly move data between CSV files, JSON APIs, in-memory objects, and databases. Bugs happen when we conflate different representations — treating a matrix like a table, or a JSON array like a typed record set. This article explains the mental models and shows concrete Go (and Python/JS) code for each pattern.
Table vs Matrix: Not the Same Thing
Matrix
A matrix is a 2D structure where every cell has the same type — usually numeric. It’s shaped m × n and optimized for linear algebra.
// Go matrix: [][]float64
matrix := [][]float64{
{1.0, 2.0, 3.0},
{4.0, 5.0, 6.0},
{7.0, 8.0, 9.0},
}
// Matrix operations
func transpose(m [][]float64) [][]float64 {
rows, cols := len(m), len(m[0])
t := make([][]float64, cols)
for i := range t {
t[i] = make([]float64, rows)
for j := range t[i] {
t[i][j] = m[j][i]
}
}
return t
}
func dotProduct(a, b []float64) float64 {
var sum float64
for i := range a {
sum += a[i] * b[i]
}
return sum
}
Table
A table is row/column data where columns have different types. It represents real-world entities — users, orders, events.
// Go table: []struct
type Order struct {
ID string
CustomerID string
Amount float64
Status string
CreatedAt time.Time
}
orders := []Order{
{ID: "ord-1", CustomerID: "c-100", Amount: 49.99, Status: "paid", CreatedAt: time.Now()},
{ID: "ord-2", CustomerID: "c-101", Amount: 12.50, Status: "pending", CreatedAt: time.Now()},
}
Key difference: matrix rows are interchangeable (same type); table rows represent records with schema (different types per column).
Row-Oriented vs Column-Oriented Models
Row-Oriented (Record Model)
Each row is a complete record. Natural for APIs, CRUD operations, and transactional workloads.
// Row model in Go — []struct
type Event struct {
UserID string
EventType string
Timestamp time.Time
Value float64
}
events := []Event{
{"u1", "click", time.Now(), 1.0},
{"u2", "view", time.Now(), 1.0},
}
// API serialization — natural with row model
json.NewEncoder(w).Encode(events)
// Per-record access — O(1)
event := events[42]
Column-Oriented (Columnar Model)
Each column is a separate slice. Faster for analytics (scan one column, skip others), better compression.
// Column model — separate slices per field
type EventColumns struct {
UserIDs []string
EventTypes []string
Timestamps []time.Time
Values []float64
}
cols := EventColumns{
UserIDs: []string{"u1", "u2", "u3"},
EventTypes: []string{"click", "view", "click"},
Values: []float64{1.0, 1.0, 2.0},
}
// Analytics scan — only touches the Values slice
var totalClicks float64
for i, t := range cols.EventTypes {
if t == "click" {
totalClicks += cols.Values[i]
}
}
Apache Arrow (used by Parquet, DuckDB, Polars) is the standard columnar format for Go data engineering.
Language Comparison
| Concept | Go | Python | JavaScript | Database |
|---|---|---|---|---|
| Single record | struct |
dict / @dataclass |
object |
row / document |
| Table (records) | []struct |
list[dict] / DataFrame |
array of objects | table / collection |
| Matrix | [][]float64 |
numpy.ndarray |
nested arrays | numeric column arrays |
| Schema | struct type | TypedDict / dataclass | TypeScript interface | CREATE TABLE |
| Column access | for _, r := range rows { r.Name } |
df["name"] |
rows.map(r => r.name) |
SELECT name FROM ... |
Go Table Patterns
Typed Record Set with Filtering
package main
import (
"fmt"
"strings"
"time"
)
type User struct {
ID string
Name string
Email string
Role string
CreatedAt time.Time
Active bool
}
type UserTable []User
func (t UserTable) Filter(fn func(User) bool) UserTable {
result := make(UserTable, 0)
for _, u := range t {
if fn(u) {
result = append(result, u)
}
}
return result
}
func (t UserTable) Map(fn func(User) User) UserTable {
result := make(UserTable, len(t))
for i, u := range t {
result[i] = fn(u)
}
return result
}
func (t UserTable) GroupBy(keyFn func(User) string) map[string]UserTable {
groups := make(map[string]UserTable)
for _, u := range t {
key := keyFn(u)
groups[key] = append(groups[key], u)
}
return groups
}
func (t UserTable) Find(fn func(User) bool) (User, bool) {
for _, u := range t {
if fn(u) { return u, true }
}
return User{}, false
}
func main() {
users := UserTable{
{ID: "u1", Name: "Alice", Role: "admin", Active: true},
{ID: "u2", Name: "Bob", Role: "user", Active: true},
{ID: "u3", Name: "Carol", Role: "admin", Active: false},
{ID: "u4", Name: "Dave", Role: "user", Active: true},
}
// Filter active users
active := users.Filter(func(u User) bool { return u.Active })
fmt.Printf("Active: %d\n", len(active)) // 3
// Group by role
byRole := users.GroupBy(func(u User) string { return u.Role })
fmt.Printf("Admins: %d, Users: %d\n", len(byRole["admin"]), len(byRole["user"]))
// Find specific user
admin, found := users.Find(func(u User) bool {
return u.Role == "admin" && u.Active
})
if found { fmt.Println("First active admin:", admin.Name) }
}
Reading CSV into a Table
import (
"encoding/csv"
"fmt"
"os"
"strconv"
"time"
)
type Product struct {
ID string
Name string
Price float64
Stock int
}
func ReadProductsCSV(path string) ([]Product, error) {
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("opening %s: %w", path, err)
}
defer f.Close()
r := csv.NewReader(f)
r.TrimLeadingSpace = true
// Skip header
if _, err := r.Read(); err != nil {
return nil, err
}
var products []Product
for {
record, err := r.Read()
if err != nil {
break // EOF
}
if len(record) < 4 {
continue // skip malformed rows
}
price, _ := strconv.ParseFloat(record[2], 64)
stock, _ := strconv.Atoi(record[3])
products = append(products, Product{
ID: record[0],
Name: record[1],
Price: price,
Stock: stock,
})
}
return products, nil
}
JSON API ↔ Database Model Transformation
A common pattern: different shapes for API vs storage:
// API shape (what clients send/receive)
type OrderRequest struct {
CustomerID string `json:"customer_id"`
Items []struct {
ProductID string `json:"product_id"`
Quantity int `json:"quantity"`
} `json:"items"`
}
type OrderResponse struct {
ID string `json:"id"`
CustomerID string `json:"customer_id"`
Total float64 `json:"total"`
Status string `json:"status"`
CreatedAt time.Time `json:"created_at"`
Items []Item `json:"items"`
}
// Database shape (flat, normalized)
type OrderRow struct {
ID string
CustomerID string
Total float64
Status string
CreatedAt time.Time
}
type OrderItemRow struct {
OrderID string
ProductID string
Quantity int
UnitPrice float64
}
// Transform: DB rows → API response
func toOrderResponse(order OrderRow, items []OrderItemRow) OrderResponse {
apiItems := make([]Item, len(items))
for i, item := range items {
apiItems[i] = Item{
ProductID: item.ProductID,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
Subtotal: float64(item.Quantity) * item.UnitPrice,
}
}
return OrderResponse{
ID: order.ID,
CustomerID: order.CustomerID,
Total: order.Total,
Status: order.Status,
CreatedAt: order.CreatedAt,
Items: apiItems,
}
}
Schema Drift and Validation
Schema drift happens when data changes shape over time — age becomes a string in one source, timestamps lose timezone info, fields go missing. Catch it at ingestion:
import (
"fmt"
"strings"
"time"
)
type RawRecord map[string]interface{}
type ValidationError struct {
Field string
Message string
}
func (e ValidationError) Error() string {
return fmt.Sprintf("field %q: %s", e.Field, e.Message)
}
func validateOrderRecord(r RawRecord) (*Order, []error) {
var errs []error
// Required string field
id, ok := r["id"].(string)
if !ok || id == "" {
errs = append(errs, ValidationError{"id", "required string"})
}
// Numeric field — might arrive as string from CSV
var amount float64
switch v := r["amount"].(type) {
case float64:
amount = v
case string:
fmt.Sscanf(v, "%f", &amount)
default:
errs = append(errs, ValidationError{"amount", "expected number"})
}
if amount <= 0 {
errs = append(errs, ValidationError{"amount", "must be positive"})
}
// Timestamp normalization
var createdAt time.Time
if ts, ok := r["created_at"].(string); ok {
for _, layout := range []string{time.RFC3339, "2006-01-02", "2006-01-02 15:04:05"} {
if t, err := time.Parse(layout, ts); err == nil {
createdAt = t
break
}
}
if createdAt.IsZero() {
errs = append(errs, ValidationError{"created_at", "unrecognized timestamp format"})
}
}
if len(errs) > 0 {
return nil, errs
}
return &Order{ID: id, Amount: amount, CreatedAt: createdAt}, nil
}
Dynamic Tables with map[string]interface{}
Sometimes you need to handle unknown schemas (building a query engine, data exploration tool):
type DynamicTable struct {
Columns []string
Rows []map[string]interface{}
}
func (t *DynamicTable) AddRow(row map[string]interface{}) {
// Auto-discover new columns
for k := range row {
found := false
for _, col := range t.Columns {
if col == k { found = true; break }
}
if !found {
t.Columns = append(t.Columns, k)
}
}
t.Rows = append(t.Rows, row)
}
func (t *DynamicTable) Column(name string) []interface{} {
result := make([]interface{}, len(t.Rows))
for i, row := range t.Rows {
result[i] = row[name]
}
return result
}
func (t *DynamicTable) Filter(col string, fn func(interface{}) bool) *DynamicTable {
filtered := &DynamicTable{Columns: t.Columns}
for _, row := range t.Rows {
if fn(row[col]) {
filtered.Rows = append(filtered.Rows, row)
}
}
return filtered
}
Python and JavaScript Comparison
# Python: row model (list of dicts)
users = [
{"id": "u1", "name": "Alice", "active": True},
{"id": "u2", "name": "Bob", "active": False},
]
# Filter — equivalent to Go's Filter method
active = [u for u in users if u["active"]]
# Column access — extract one field across all rows
names = [u["name"] for u in users]
# DataFrame (column-oriented)
import pandas as pd
df = pd.DataFrame(users)
active_df = df[df["active"]]
names = df["name"].tolist()
// JavaScript: array of objects (same as Python list-of-dict)
const users = [
{ id: "u1", name: "Alice", active: true },
{ id: "u2", name: "Bob", active: false },
];
const active = users.filter(u => u.active);
const names = users.map(u => u.name);
// Reduce to column model
const grouped = users.reduce((acc, u) => {
(acc[u.active ? "active" : "inactive"] ||= []).push(u);
return acc;
}, {});
Choosing the Right Model
| Workload | Use |
|---|---|
| REST API request/response | []struct or struct |
| Database rows | []struct matching table schema |
| CSV import/export | []struct with csv tags |
| Analytics, aggregations | Column model or Apache Arrow |
| Linear algebra, ML features | [][]float64 or gonum |
| Unknown schema at compile time | []map[string]interface{} |
| Cross-language data exchange | JSON (row) or Parquet/Arrow (column) |
Summary
- Matrix = uniform type, 2D, math operations →
[][]float64 - Table = typed columns, record-oriented →
[]struct - Row model = fast per-record access, natural for APIs
- Column model = fast analytics scans, better compression
- Always validate and normalize at ingestion boundaries — schema drift causes data quality incidents downstream
- Use typed structs in Go rather than
map[string]interface{}everywhere — the compiler catches mismatches
Comments