Understanding ORMs by Building a Tiny One in Go with Reflect and Struct Tags
Takashi Yamamoto
Infrastructure Engineer · Leapcell

Building a Tiny Go ORM to Unravel its Inner Workings
The world of modern application development often involves interacting with databases. While writing raw SQL queries offers ultimate control, it can become cumbersome and error-prone for complex data structures. This is where Object-Relational Mappers (ORMs) come into play. ORMs bridge the gap between object-oriented programming languages and relational databases, allowing developers to interact with database records as if they were native programming language objects. This abstraction layer significantly boosts productivity and readability. However, the magic of ORMs often obscures their underlying mechanisms.
This article aims to demystify ORMs by building a rudimentary one in Go. By understanding how a simple ORM can be constructed using Go's reflect package and struct tags, we'll gain a deeper appreciation for their utility and the engineering principles behind them. This journey will illuminate the core challenges an ORM addresses and the ingenious ways in which reflection provides a solution.
The Building Blocks of Database Abstraction
Before diving into the implementation, let's define some key concepts that are central to ORMs and our tiny project:
- ORM (Object-Relational Mapper): A programming tool that maps a database schema to an object model in a programming language. It enables developers to manipulate data in a database using the programming language's paradigm, abstracting away the complexities of SQL.
 - Reflection: In computer science, reflection is the ability of a computer program to examine, introspect, and modify its own structure and behavior at runtime. Go's 
reflectpackage provides powerful tools for this, allowing us to inspect struct fields, their types, and their values dynamically. - Struct Tags: Metadata strings that can be attached to struct fields in Go. These tags are ignored by the Go compiler but can be accessed at runtime using reflection. They are perfect for providing instructions to libraries or ORMs about how to handle specific fields, such as mapping them to database column names or defining validation rules.
 - Database Driver: A software component that allows an application to interact with a specific database system (e.g., MySQL driver, PostgreSQL driver). Our ORM will rely on Go's 
database/sqlpackage, which provides a generic interface to interact with various drivers. 
The core principle behind our simple ORM will be to use reflection to inspect a Go struct, extract information about its fields (like their names and types), and then use struct tags to map these fields to corresponding database table columns. This mapping will allow us to construct dynamic SQL queries for common operations like INSERT and SELECT.
The Fundamental ORM Operations: Storing and Retrieving
Our minimalist ORM will focus on two foundational operations: saving a new record to the database and loading an existing record.
Let's start by defining a simple User struct that represents a database table. We'll use struct tags to specify the database column names.
package main import ( "database/sql" "fmt" "reflect" "strings" _ "github.com/go-sql-driver/mysql" // Replace with your desired database driver ) // User represents a user in the database type User struct { ID int `db:"id"` Name string `db:"name"` Email string `db:"email"` IsActive bool `db:"is_active"` CreatedAt string `db:"created_at"` // For simplicity, using string for datetime } // Our simple ORM functions will interact with this global DB connection var db *sql.DB func init() { var err error // Replace with your actual database connection string // Example for MySQL: "user:password@tcp(127.0.0.1:3306)/database_name" db, err = sql.Open("mysql", "root:password@tcp(127.0.0.1:3306)/my_database") if err != nil { panic(err) } err = db.Ping() if err != nil { panic(err) } fmt.Println("Successfully connected to the database!") // Create a simple table for demonstration if it doesn't exist _, err = db.Exec(`CREATE TABLE IF NOT EXISTS users ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL, email VARCHAR(255) UNIQUE NOT NULL, is_active BOOLEAN DEFAULT TRUE, created_at VARCHAR(255) )`) if err != nil { panic(err) } fmt.Println("Table 'users' ensured.") } // Insert takes a struct and inserts it into the corresponding table // It assumes the table name is the lowercase plural of the struct name. // It relies on struct tags `db` to map fields to column names. func Insert(obj interface{}) (sql.Result, error) { val := reflect.ValueOf(obj) typ := reflect.TypeOf(obj) if typ.Kind() != reflect.Struct { return nil, fmt.Errorf("Insert expects a struct, got %s", typ.Kind()) } tableName := strings.ToLower(typ.Name()) + "s" // Simple pluralization var columns []string var placeholders []string var values []interface{} for i := 0; i < val.NumField(); i++ { field := typ.Field(i) fieldVal := val.Field(i) dbTag := field.Tag.Get("db") if dbTag == "" || strings.EqualFold(dbTag, "id") { // Skip empty tags or ID for auto-increment continue } columns = append(columns, dbTag) placeholders = append(placeholders, "?") // MySQL placeholder values = append(values, fieldVal.Interface()) } query := fmt.Sprintf("INSERT INTO %s (%s) VALUES (%s)", tableName, strings.Join(columns, ", "), strings.Join(placeholders, ", ")) fmt.Printf("Executing query: %s with values: %v\n", query, values) return db.Exec(query, values...) } // FindByID retrieves a record from the database and populates the given struct pointer. // It assumes the table name is the lowercase plural of the struct name. // It relies on struct tags `db` to map fields to column names. func FindByID(id int, objPtr interface{}) error { val := reflect.ValueOf(objPtr) if val.Kind() != reflect.Ptr || val.IsNil() { return fmt.Errorf("FindByID expects a non-nil struct pointer") } elem := val.Elem() if elem.Kind() != reflect.Struct { return fmt.Errorf("FindByID expects a pointer to a struct, got %s", elem.Kind()) } typ := elem.Type() tableName := strings.ToLower(typ.Name()) + "s" var columns []string var dest []interface{} // Pointers to fields for Scan for i := 0; i < elem.NumField(); i++ { field := typ.Field(i) fieldVal := elem.Field(i) dbTag := field.Tag.Get("db") if dbTag == "" { // Skip fields without a db tag continue } columns = append(columns, dbTag) dest = append(dest, fieldVal.Addr().Interface()) // Get address of the field } query := fmt.Sprintf("SELECT %s FROM %s WHERE id = ?", strings.Join(columns, ", "), tableName) fmt.Printf("Executing query: %s with ID: %d\n", query, id) row := db.QueryRow(query, id) return row.Scan(dest...) } func main() { defer db.Close() // 1. Insert a new user newUser := User{ Name: "Alice Smith", Email: "alice@example.com", IsActive: true, CreatedAt: "2023-10-27 10:00:00", } result, err := Insert(newUser) if err != nil { fmt.Printf("Error inserting user: %v\n", err) } else { id, _ := result.LastInsertId() fmt.Printf("User inserted with ID: %d\n", id) } // 2. Find a user by ID var fetchedUser User err = FindByID(1, &fetchedUser) // Assuming ID 1 exists if err != nil { fmt.Printf("Error finding user: %v\n", err) } else { fmt.Printf("Fetched user: %+v\n", fetchedUser) } // 3. Insert another user to demonstrate different data anotherUser := User{ Name: "Bob Johnson", Email: "bob@example.com", IsActive: false, CreatedAt: "2023-10-27 11:30:00", } result, err = Insert(anotherUser) if err != nil { fmt.Printf("Error inserting another user: %v\n", err) } else { id, _ := result.LastInsertId() fmt.Printf("Another user inserted with ID: %d\n", id) } var fetchedAnotherUser User err = FindByID(2, &fetchedAnotherUser) if err != nil { fmt.Printf("Error finding another user: %v\n", err) } else { fmt.Printf("Fetched another user: %+v\n", fetchedAnotherUser) } }
Explanation of the Code:
- 
mainandinitfunctions:- The 
initfunction establishes a global*sql.DBconnection to a MySQL database (you'll need to replace the connection string with your own and ensure thegithub.com/go-sql-driver/mysqldriver is imported, or use another database driver). - It also ensures the 
userstable exists for our example. mainorchestrates the demonstration, callingInsertandFindByID.
 - The 
 - 
Userstruct:- Each field has a 
dbstruct tag, likeID int \db:"id"`. This tag tells our ORM precisely which database column the field maps to. TheIDfield is marked asid` which we'll treat specially as it's typically auto-incremented. 
 - Each field has a 
 - 
Insertfunction:- It takes an 
obj(an interface{}) which is expected to be a struct. reflect.ValueOf(obj)andreflect.TypeOf(obj)are used to get the reflection values and types.- It then iterates through the struct's fields:
field := typ.Field(i)gives usreflect.StructFieldmetadata (like name, type, tags).fieldVal := val.Field(i)gives usreflect.Valuefor the actual field content.dbTag := field.Tag.Get("db")extracts the value of thedbstruct tag.- Fields with an empty tag or the tag "id" are skipped for insertion, assuming 
idis auto-incremented. columns,placeholders, andvaluesslices are built dynamically based on the struct's fields and their values.
 - Finally, 
fmt.Sprintfconstructs theINSERTSQL query, which is then executed usingdb.Exec. 
 - It takes an 
 - 
FindByIDfunction:- It takes an 
idand a pointer to a struct (objPtr) where the fetched data will be stored. reflect.ValueOf(objPtr).Elem()is crucial here. We needobjPtras a pointer so we can modify the struct it points to.Elem()dereferences the pointer to get the underlying structreflect.Value.- Similar to 
Insert, it iterates through the struct's fields. dest := append(dest, fieldVal.Addr().Interface())is key for scanning.Addr()returns areflect.Valuerepresenting the address of the field, andInterface()converts it to aninterface{}, which is whatrow.Scanexpects to populate the fields directly.- A 
SELECTquery is constructed, anddb.QueryRow().Scan(dest...)executes it and populates the struct fields directly via their pointers. 
 - It takes an 
 
Why Reflection and Struct Tags?
Imagine trying to write an Insert or FindByID function without reflect. You would have to write a separate function for each struct type (InsertUser, FindUserByID, InsertProduct, FindProductByID, etc.), and manually map each struct field to its database column in the SQL query. This would lead to enormous code duplication and be a maintenance nightmare.
Reflection allows us to write generic functions that can operate on any struct, dynamically inspecting its structure and extracting the necessary information at runtime. Struct tags provide a clean, declarative way to add metadata to our structs, guiding the reflection process without cluttering the business logic of the struct itself. They act as configuration for our ORM.
Conclusion
By constructing this "very simple" ORM, we've touched upon the core principles that power more sophisticated ORM frameworks. We've seen how Go's reflect package enables dynamic introspection of types and values, and how struct tags provide essential metadata for mapping Go objects to database schemas. This combination allows for generic database interaction functions, drastically reducing boilerplate code and improving maintainability. While our ORM lacks features like transaction management, complex query building, or relationship handling, it clearly demonstrates the fundamental mechanism: bridging the object and relational worlds through runtime metadata interpretation. ORMs, at their heart, are powerful tools that abstract database interactions by intelligently using reflection and convention (often guided by struct tags) to transform data between application objects and database records.

