Why Go Developers Can’t Stop Arguing About ORMs
You’ve seen it in every Go forum, every Reddit thread, every heated discussion at tech meetups: someone mentions using GORM, and suddenly you’re watching developers passionately defend their choice of database interaction like it’s a lifestyle decision. Team ORM insists raw SQL is archaic and error-prone. Team Raw SQL claims ORMs are bloated magic that hide what’s really happening.
Here’s the truth nobody wants to admit: both sides are right, and both sides are wrong. The real answer to “ORM or raw SQL?” isn’t which one is better — it’s understanding when to use each approach. Let’s cut through the noise and figure out what actually makes sense for your Go application.
Understanding the Two Worlds
Before we jump into the battle, let’s make sure we’re speaking the same language. Raw SQL in Go means using the standard database/sql package to write and execute SQL queries directly. You write the SQL, you manage the connections, and you have complete control over every database interaction.
ORMs (Object-Relational Mappers) like GORM, SQLX, or Ent provide a layer of abstraction over your database. Instead of writing SQL strings, you work with Go structs and methods that generate SQL for you. Think of it as translating between Go’s object-oriented style and SQL’s relational model.
Neither approach is inherently superior — they’re tools with different strengths and trade-offs. The key is knowing which tool fits your specific needs.
Raw SQL: The Unfiltered Truth
Let’s start with raw SQL using Go’s standard library. Here’s what a simple user query looks like:
type User struct {
ID int
Name string
Email string
}
func getUserByID(db *sql.DB, id int) (*User, error) {
user := &User{}
query := `SELECT id, name, email FROM users WHERE id = ?`
err := db.QueryRow(query, id).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
return nil, err
}
return user, nil
}
func createUser(db *sql.DB, name, email string) error {
query := `INSERT INTO users (name, email) VALUES (?, ?)`
_, err := db.Exec(query, name, email)
return err
}Notice how explicit everything is. You see exactly what SQL is being executed, exactly which fields are being retrieved, and exactly how errors are handled. There’s no magic, no hidden behavior — just straightforward database operations.
This transparency is both raw SQL’s greatest strength and its most demanding characteristic. You control everything, which means you’re also responsible for everything.
Enter the ORM: GORM in Action
Now let’s look at the same operations using GORM, one of Go’s most popular ORMs:
type User struct {
ID uint `gorm:"primaryKey"`
Name string `gorm:"not null"`
Email string `gorm:"unique;not null"`
}
func getUserByID(db *gorm.DB, id uint) (*User, error) {
var user User
result := db.First(&user, id)
return &user, result.Error
}
func createUser(db *gorm.DB, name, email string) error {
user := User{Name: name, Email: email}
return db.Create(&user).Error
}The code is noticeably more concise. The struct tags define database constraints, and GORM handles generating the SQL queries for you. There’s no explicit SQL, no manual scanning of results — GORM manages these details automatically.
This convenience comes with a cost: you’re trusting GORM to generate efficient SQL and handle edge cases correctly. When it works well, it’s beautiful. When it doesn’t, debugging can be frustrating.
Performance: The Numbers Don’t Lie (But Context Matters)
Let’s address the elephant in the room: performance. Raw SQL is generally faster than ORMs because there’s no abstraction layer translating your code into SQL. But here’s the critical question: does it matter for your application?
For most web applications, the performance difference is negligible compared to network latency, business logic, and other bottlenecks. If you’re serving thousands of requests per second with complex queries, yes, raw SQL’s performance edge might matter. If you’re building a typical CRUD application, the difference is probably imperceptible to users.
Consider this simple benchmark scenario:
// Raw SQL
query := `SELECT * FROM users WHERE created_at > ? LIMIT 100`
rows, err := db.Query(query, time.Now().AddDate(0, -1, 0))
// GORM
var users []User
db.Where("created_at > ?", time.Now().AddDate(0, -1, 0)).Limit(100).Find(&users)The raw SQL version will execute slightly faster, but we’re talking microseconds. Unless you’re running this query millions of times per day, the performance difference won’t impact your users’ experience.
Developer Productivity: Where ORMs Shine
Here’s where ORMs start looking really attractive. Consider a common scenario: creating relationships between models. With raw SQL:
func getUserWithPosts(db *sql.DB, userID int) (*User, []*Post, error) {
user := &User{}
query := `SELECT id, name, email FROM users WHERE id = ?`
err := db.QueryRow(query, userID).Scan(&user.ID, &user.Name, &user.Email)
if err != nil {
return nil, nil, err
}
query = `SELECT id, title, content, user_id FROM posts WHERE user_id = ?`
rows, err := db.Query(query, userID)
if err != nil {
return nil, nil, err
}
defer rows.Close()
var posts []*Post
for rows.Next() {
post := &Post{}
err := rows.Scan(&post.ID, &post.Title, &post.Content, &post.UserID)
if err != nil {
return nil, nil, err
}
posts = append(posts, post)
}
return user, posts, rows.Err()
}With GORM, the same operation becomes much simpler:
type User struct {
ID uint
Name string
Email string
Posts []Post
}
type Post struct {
ID uint
Title string
Content string
UserID uint
}
func getUserWithPosts(db *gorm.DB, userID uint) (*User, error) {
var user User
err := db.Preload("Posts").First(&user, userID).Error
return &user, err
}For complex data models with many relationships, ORMs can save significant development time and reduce boilerplate code.
Complexity: When Raw SQL Becomes Your Friend
As applications grow, complex queries become inevitable. This is where raw SQL’s explicitness becomes a major advantage. Consider a reporting query with multiple joins, aggregations, and conditional logic:
query := `
SELECT
u.name,
COUNT(DISTINCT o.id) as order_count,
SUM(o.total) as total_spent,
AVG(o.total) as avg_order
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.created_at > ?
AND o.status = 'completed'
GROUP BY u.id, u.name
HAVING COUNT(DISTINCT o.id) > 5
ORDER BY total_spent DESC
LIMIT 10
`
rows, err := db.Query(query, startDate)This query is complex but completely transparent. You can read it, understand it, optimize it, and even test it directly in your database tool. Now imagine trying to construct the same query using ORM methods — it often becomes more complex than just writing the SQL.
ORMs excel at simple queries but can become awkward when dealing with complex business logic or performance-critical operations. Raw SQL gives you full control when you need it most.
Migration and Schema Management
One area where ORMs provide significant value is database migrations. GORM’s AutoMigrate feature can be incredibly convenient during development:
db.AutoMigrate(&User{}, &Post{}, &Comment{})This automatically creates or updates your database schema based on your struct definitions. It’s perfect for rapid prototyping and early development stages.
However, for production applications, many teams prefer explicit migration tools like golang-migrate or writing raw SQL migrations. These approaches give you precise control over schema changes and make it easier to review what’s actually happening to your database.
// Raw migration (using a migration tool)
CREATE TABLE users (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
email VARCHAR(255) UNIQUE NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);The explicit approach prevents surprises in production and makes database changes reviewable and auditable.
Testing Strategies: Different Approaches
Testing with raw SQL typically involves either using a real test database or creating mock interfaces:
type UserRepository interface {
GetByID(id int) (*User, error)
Create(name, email string) error
}
// Then mock the interface in tests
type MockUserRepository struct {
GetByIDFunc func(id int) (*User, error)
}ORMs often come with built-in testing support or work well with in-memory databases:
func setupTestDB() *gorm.DB {
db, _ := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
db.AutoMigrate(&User{})
return db
}
func TestCreateUser(t *testing.T) {
db := setupTestDB()
user := User{Name: "Test", Email: "test@example.com"}
result := db.Create(&user)
if result.Error != nil {
t.Fatal(result.Error)
}
if user.ID == 0 {
t.Error("Expected user ID to be set")
}
}Both approaches work well, but ORMs can make certain types of tests easier to write.
The Hybrid Approach: Best of Both Worlds
Here’s a secret many experienced Go developers discover: you don’t have to choose just one approach. Some of the best applications use both:
// Use ORM for simple CRUD operations
func (s *UserService) CreateUser(name, email string) error {
user := User{Name: name, Email: email}
return s.db.Create(&user).Error
}
// Use raw SQL for complex queries
func (s *UserService) GetTopCustomers(limit int) ([]CustomerReport, error) {
query := `
SELECT u.id, u.name, SUM(o.total) as lifetime_value
FROM users u
JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name
ORDER BY lifetime_value DESC
LIMIT ?
`
rows, err := s.db.Raw(query, limit).Rows()
// ... process rows
}This hybrid approach lets you leverage ORM convenience for standard operations while maintaining control over performance-critical or complex queries. Many ORMs, including GORM, support executing raw SQL when needed.
Real-World Decision Framework
So how do you actually decide? Here’s a practical framework based on project characteristics:
Choose raw SQL when:
- Performance is critical and every millisecond counts
- Your queries are highly complex with many joins and subqueries
- You need precise control over database interactions
- Your team consists of experienced SQL developers
- You’re working with stored procedures or database-specific features
Choose an ORM when:
- You’re building a standard CRUD application
- Developer productivity and rapid development are priorities
- Your team is more comfortable with Go than SQL
- You want built-in migration and schema management
- Relationships between models are straightforward
Consider the hybrid approach when:
- You need both convenience and performance
- Different parts of your application have different requirements
- You want to leverage ORM features while maintaining control
- Your application is large enough to benefit from both tools
Learning Curve and Team Dynamics
The human factor matters more than technical considerations in many cases. A team of experienced SQL developers might be more productive with raw SQL, while a team more comfortable with Go might prefer an ORM.
Raw SQL requires solid SQL knowledge, understanding of query optimization, and awareness of database-specific features. ORMs abstract much of this complexity but introduce their own learning curve — understanding how the ORM generates SQL, how to optimize ORM queries, and when to drop down to raw SQL.
Consider your team’s current skills and where you want to invest learning time. Both approaches are valuable skills, but they represent different knowledge domains.
Maintenance and Long-Term Considerations
Think about your application six months, a year, or five years down the road. Raw SQL queries are explicit and self-documenting — any developer familiar with SQL can understand them. However, they can lead to duplicate code if not properly abstracted.
ORMs reduce boilerplate and provide consistency, but they can hide performance problems until they become serious. A query that works fine with 1,000 records might crawl to a halt at 100,000 records, and the ORM abstraction can make the problem harder to diagnose.
Version upgrades matter too. ORMs evolve, sometimes introducing breaking changes or changing query generation behavior. Raw SQL is more stable — the SQL standard changes slowly, and database engines maintain excellent backward compatibility.
The Bottom Line: It’s Not About Being Right
The ORM versus raw SQL debate often becomes religious, with developers passionately defending their chosen approach. But the reality is more nuanced. The best choice depends on your specific context: your team, your application’s requirements, your performance needs, and your maintenance capabilities.
Many successful Go applications use raw SQL exclusively. Many others use ORMs throughout. Still others mix both approaches based on specific needs. All of these choices can be correct given the right circumstances.
Instead of asking “which is better,” ask “which approach best serves my current needs?” Be willing to change your answer as your application evolves. The flexibility to choose — and to change your mind — is one of Go’s greatest strengths.
What matters most isn’t whether you use an ORM or raw SQL, but that you understand the trade-offs, make conscious decisions, and write code that your team can maintain. Focus on building applications that work well, deploy reliably, and serve your users effectively. The database interaction layer is just one piece of that puzzle.
Where do you stand in the ORM versus raw SQL debate? Have you found a hybrid approach that works well for your team? What unexpected challenges or benefits have you discovered with either approach? Share your experiences and insights in the comments below — let’s learn from each other’s real-world projects!
