The data shape dictates the tool
You're building a service that tracks user activity. You need to store events, aggregate them by hour, and join them with user profiles. You also want to add new fields to events without migrating the whole schema. You open your editor, stare at the go.mod file, and wonder whether to pull in a PostgreSQL driver or a MongoDB client.
The choice isn't just about the database engine. It's about how Go handles data, how your code will evolve, and what kind of pain you're willing to debug at 3 AM. SQL and NoSQL represent different trade-offs between structure, flexibility, and operational complexity. Go treats both as external services, but the APIs and patterns you write will look very different.
Structure versus flexibility
SQL databases enforce a schema. You define the columns, the types, and the relationships before you insert a single row. The database rejects anything that doesn't fit. This strictness prevents garbage data from entering the system. It also means you have to plan your data model carefully and run migrations when the model changes.
NoSQL databases, specifically document stores like MongoDB, store data as flexible documents. You can insert a JSON-like object with whatever fields you want, and the next object can have completely different fields. The database doesn't care about consistency across documents. This makes development fast when the data shape is unknown or changes frequently. It also shifts the burden of validation to your Go code.
In Go terms, SQL maps naturally to structs with strict fields. NoSQL maps to structs with tags or even map[string]interface{} when the shape varies wildly.
Minimal examples
Here's the standard pattern for SQL in Go. You open a connection pool, prepare a statement, and execute it with parameters.
package main
import (
"database/sql"
"fmt"
_ "github.com/lib/pq" // side-effect import registers the postgres driver
)
// User represents a row in the users table.
type User struct {
ID int
Name string
Email string
}
func main() {
// Open returns a pool, not a single connection.
// The driver is lazy; it doesn't connect until you use the pool.
db, err := sql.Open("postgres", "user=postgres dbname=mydb sslmode=disable")
if err != nil {
panic(err)
}
defer db.Close() // release the pool when main exits
// Scan copies data from the database row into Go variables.
var u User
err = db.QueryRow("SELECT id, name, email FROM users WHERE id = $1", 1).Scan(&u.ID, &u.Name, &u.Email)
if err != nil {
panic(err)
}
fmt.Println(u)
}
NoSQL drivers look different. You usually connect to a client, select a database and collection, and pass structs directly to insert or find methods.
package main
import (
"context"
"fmt"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/mongo"
"go.mongodb.org/mongo-driver/mongo/options"
)
// Event holds activity data. Fields can vary per document.
type Event struct {
UserID int `bson:"user_id"`
Action string `bson:"action"`
Meta bson.M `bson:"meta,omitempty"` // flexible map for arbitrary data
}
func main() {
// Connect creates a client that manages a pool of connections.
client, err := mongo.Connect(context.Background(), options.Client().ApplyURI("mongodb://localhost:27017"))
if err != nil {
panic(err)
}
defer client.Disconnect(context.Background()) // close connections on exit
// InsertOne marshals the struct to BSON and sends it to the server.
collection := client.Database("app").Collection("events")
_, err = collection.InsertOne(context.Background(), Event{UserID: 1, Action: "login"})
if err != nil {
panic(err)
}
fmt.Println("Inserted")
}
What happens under the hood
database/sql is part of the standard library. It defines an interface for drivers. When you call sql.Open, you get a *sql.DB object. This is a connection pool, not a single connection. The pool handles concurrency so multiple goroutines can query safely. The driver you imported via _ "github.com/lib/pq" registers itself behind the scenes. When you call QueryRow, the pool checks out a connection, sends the SQL, reads the result, and puts the connection back.
If you forget to import the driver, the compiler rejects the program with driver "postgres" is not registered. The blank identifier import is a Go convention for packages that have side effects.
NoSQL drivers are specific to the database. The MongoDB client also manages a connection pool, but the API is different. You pass structs to methods, and the driver marshals them to BSON (Binary JSON). The driver handles the network protocol details. You don't write raw queries; you build query objects or use the driver's API.
Run gofmt on your code. The Go community expects consistent formatting. Most editors do this automatically. Don't argue about indentation; let the tool decide.
Realistic patterns
Real code involves context, error handling, and struct mapping. Here's a realistic SQL query with context and error wrapping.
// GetUser retrieves a user by ID from the database.
func GetUser(ctx context.Context, db *sql.DB, id int) (*User, error) {
// Context cancels the query if the HTTP request times out.
row := db.QueryRowContext(ctx, "SELECT id, name, email FROM users WHERE id = $1", id)
var u User
// Scan returns sql.ErrNoRows if the query finds nothing.
err := row.Scan(&u.ID, &u.Name, &u.Email)
if err != nil {
return nil, fmt.Errorf("get user %d: %w", id, err)
}
return &u, nil
}
Here's the NoSQL equivalent. The driver uses context for cancellation too.
// FindEvent retrieves an event by ID from MongoDB.
func FindEvent(ctx context.Context, col *mongo.Collection, id string) (*Event, error) {
// Filter matches the _id field. The driver converts the string to an ObjectId.
filter := bson.M{"_id": id}
var event Event
// FindOne decodes the BSON document directly into the struct.
err := col.FindOne(ctx, filter).Decode(&event)
if err != nil {
return nil, fmt.Errorf("find event %s: %w", id, err)
}
return &event, nil
}
context.Context always goes as the first parameter, conventionally named ctx. Functions that take a context should respect cancellation and deadlines. If you pass a context to a query and the context never cancels, the database connection stays open. The pool fills up. The app hangs. Always pass context from the caller.
Pitfalls and errors
SQL injection is impossible if you use parameters. If you concatenate strings, the compiler won't stop you, but the database might reject the query or you'll leak data. Always use $1, $2 or ? placeholders.
If you forget to handle sql.ErrNoRows, your code panics or returns a zero-value struct. The compiler won't warn you. You have to check the error manually. If you scan into a variable of the wrong type, the driver returns an error like sql: Scan error on column index 0: unsupported Scan, storing driver.Value type []uint8 into type *string.
NoSQL pitfalls center on schema drift. You insert a document with Action: "login". Next week, someone inserts Action: "log_in". Your code breaks when it tries to decode. The driver won't complain about missing fields unless you check errors. If a field is missing, the struct field gets its zero value. The compiler complains with cannot use x (type int) as string value in assignment if you mix up types in your code, but the database won't stop you from storing inconsistent data.
Goroutine leaks happen when the goroutine waits on a channel that never gets closed. Always have a cancellation path. Database calls are no different. If a goroutine holds a database connection and waits on a context that never fires, the connection leaks.
The if err != nil { return err } pattern is verbose by design. The community accepts the boilerplate because it makes the unhappy path visible. Don't hide errors behind silent returns.
Operational cost
SQL databases often require a DBA mindset. Backups, replication, vacuuming, and index tuning matter. Tools like golang-migrate or atlas help manage schema changes. Migrations are code. You version them and run them in order.
NoSQL can be easier to scale horizontally for write-heavy workloads. Sharding is often built in. But ad-hoc queries are harder. You can't just run SELECT * FROM events WHERE meta->>'type' = 'click' without an index. Aggregations that are simple in SQL can become complex pipelines in NoSQL.
Pick the tool that matches your data, not the hype. If your data has relationships, use SQL. If your data is a blob of JSON that changes every sprint, use NoSQL.
When to use what
Use SQL when your data has strict relationships and you need joins. Use SQL when you require ACID transactions across multiple tables. Use SQL when the schema is stable and well-understood. Use SQL when you need complex aggregations that are hard to express in application code.
Use NoSQL when your data shape changes frequently during development. Use NoSQL when you need to store hierarchical or document-like data without flattening it. Use NoSQL when you need horizontal scaling for write-heavy workloads. Use NoSQL when you're building a content management system or a logging pipeline where flexibility matters more than consistency.
SQL is a contract. The database enforces it so your code doesn't have to. NoSQL gives you freedom. You pay for it with application-level validation.