As a JavaScript developer, you’re probably used to ORMs like Sequelize, TypeORM, or Prisma. You define schemas, write migrations, and explicitly map database columns to object properties. Everything is typed out, configured, and visible.
Then you encounter Rails ActiveRecord and it feels like the entire database just materialized into your models with zero configuration. You have getters, setters, query methods, validations, and associations—all without writing a single line of code defining your attributes.
The first time I saw a Rails model with three lines of code querying a database table with 20 columns, I was suspicious. Where are the column definitions?
JavaScript ORMs vs ActiveRecord
JavaScript: Schema in Code
In TypeScript with Prisma or TypeORM, you explicitly define everything:
class User {
@Column()
id: number;
@Column()
email: string;
@Column()
firstName: string;
@Column()
lastName: string;
@Column()
createdAt: Date;
getFullName(): string {
return `${this.firstName} ${this.lastName}`;
}
}
This is the Data Mapper pattern—you explicitly declare your model’s structure and the ORM maps it.
Rails: Schema in Database
In Rails:
class User < ApplicationRecord
def full_name
"#{first_name} #{last_name}"
end
end
No column definitions. No type declarations. Rails reads your database schema at runtime and creates the attribute methods. This is the Active Record pattern—the table structure defines what your model can do.
How ActiveRecord Knows Your Schema
When Rails boots, it connects to your database and reads each table’s schema. For a users table with columns id, email, first_name, last_name, and created_at, Rails generates:
# Getters
user.id
user.email
user.first_name
# Setters
user.email = "new@example.com"
user.first_name = "Jane"
# Query methods
user.email? # true if email is present
user.email_was # previous value before change
user.email_changed? # true if email was modified
These methods don’t exist in your code. Rails generates them from the database at runtime. In the console:
User.column_names
# => ["id", "email", "first_name", "last_name", "created_at", "updated_at"]
User.columns_hash["email"]
# => #<ActiveRecord::ConnectionAdapters::Column @name="email", @sql_type="varchar(255)">
The Mental Model Shift
In JavaScript, you think: “I’ll define my model, then sync it to the database.”
In Rails, you think: “The database is the source of truth. My model reflects it.”
Write a migration, run it, and your model automatically reflects the change. No model definition needed.
Basic CRUD Operations
Creating Records
# Create and save in one step
user = User.create(
email: "dev@example.com",
first_name: "John",
last_name: "Doe"
)
# Or build then save
user = User.new(email: "dev@example.com")
user.first_name = "John"
user.save # Returns true/false
Reading Records
user = User.find(1) # By ID (raises if not found)
user = User.find_by(email: "dev@example.com") # By criteria (returns nil)
users = User.where(active: true)
.order(created_at: :desc)
.limit(10)
Rails queries are chainable and lazy—they don’t hit the database until you iterate:
query = User.where(active: true).order(created_at: :desc) # No query yet
query = query.limit(10) # Still no query
query.each { |user| puts user.email } # NOW it executes
Updating Records
user.email = "new@example.com"
user.save
# Or update attributes at once
user.update(email: "new@example.com")
# Skip validations and callbacks (use carefully)
user.update_column(:email, "new@example.com")
Deleting Records
user.destroy
# Delete many
User.where(active: false).destroy_all
Validations
Rails validations run before saving:
class User < ApplicationRecord
validates :email, presence: true, uniqueness: true
validates :first_name, presence: true
validates :age, numericality: { greater_than: 0 }, allow_nil: true
end
Failed validations return false and populate errors:
user = User.new(first_name: "John") # Missing email
user.save # => false
user.valid? # => false
user.errors.full_messages # => ["Email can't be blank"]
user.save! # Raises ActiveRecord::RecordInvalid
Associations
Declare relationships and Rails handles the SQL:
class User < ApplicationRecord
has_many :posts
has_one :profile
belongs_to :organization
end
class Post < ApplicationRecord
belongs_to :user
has_many :comments
end
Once declared, you can traverse them:
user = User.find(1)
user.posts # All posts by this user
user.posts.count # COUNT query
user.organization # User's organization
post = Post.find(10)
post.user # Author
post.comments.where(approved: true)
# Chain across associations
User.find(1).posts.where(published: true).order(created_at: :desc).limit(5)
Avoiding N+1 Queries
# BAD - N+1 queries
users = User.all
users.each { |user| puts user.posts.count }
# 1 query for users + N queries for posts
# GOOD - Eager loading
users = User.includes(:posts)
users.each { |user| puts user.posts.count }
# 1 query for users + 1 query for all posts
Use includes to preload associations you’ll access. Use joins when filtering but not loading associated records.
Scopes
Scopes are named query fragments you can chain:
class Post < ApplicationRecord
scope :published, -> { where(published: true) }
scope :recent, -> { order(created_at: :desc).limit(10) }
scope :by_author, ->(user) { where(user: user) }
end
Post.published.recent
Post.by_author(current_user).published
Callbacks
Callbacks run code at specific points in an object’s lifecycle:
class User < ApplicationRecord
before_save :normalize_email
after_create :send_welcome_email
private
def normalize_email
self.email = email.downcase.strip
end
def send_welcome_email
UserMailer.welcome(self).deliver_later
end
end
Available callbacks: before_validation, after_validation, before_save, after_save, before_create, after_create, before_update, after_update, before_destroy, after_destroy.
Use callbacks sparingly. For complex workflows, extract to service objects.
Migrations
Migrations are Ruby classes that modify your database schema:
# db/migrate/20250119000000_create_users.rb
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :email, null: false
t.string :first_name
t.string :last_name
t.boolean :active, default: true
t.timestamps
end
add_index :users, :email, unique: true
end
end
Run with rails db:migrate. Rollback with rails db:rollback.
In Prisma, you write a schema file and generate migrations. In Rails, you write migrations directly. The database is the source of truth.
// Prisma: schema.prisma
model User {
id Int @id @default(autoincrement())
email String @unique
firstName String?
lastName String?
active Boolean @default(true)
createdAt DateTime @default(now())
}
// Then run: npx prisma migrate dev
# Rails: db/migrate/20250119000000_create_users.rb
class CreateUsers < ActiveRecord::Migration[7.0]
def change
create_table :users do |t|
t.string :email, null: false
t.string :first_name
t.string :last_name
t.boolean :active, default: true
t.timestamps
end
add_index :users, :email, unique: true
end
end
# Then run: rails db:migrate
Prisma generates migrations from your schema. Rails migrations are the schema changes—you write them directly.
Where ActiveRecord Methods Come From
class User < ApplicationRecord
# You write this ^
# But you get:
User.where(...) # ActiveRecord::QueryMethods
User.find(...) # ActiveRecord::FinderMethods
User.create(...) # ActiveRecord::Persistence
user.save # ActiveRecord::Persistence
user.valid? # ActiveRecord::Validations
has_many :posts # ActiveRecord::Associations
validates :email # ActiveRecord::Validations
before_save :... # ActiveRecord::Callbacks
end
The inheritance chain shows where it all comes from:
User.ancestors
# => [User, ApplicationRecord, ActiveRecord::Base, ...]
ActiveRecord::Base.included_modules
# => [ActiveRecord::Associations, ActiveRecord::Validations, ...]
Common Patterns
Virtual Attributes
class User < ApplicationRecord
def full_name
"#{first_name} #{last_name}"
end
def full_name=(name)
parts = name.split(" ", 2)
self.first_name = parts[0]
self.last_name = parts[1]
end
end
user.full_name = "Jane Doe"
user.first_name # => "Jane"
Enums
class Post < ApplicationRecord
enum status: { draft: 0, published: 1, archived: 2 }
end
post.draft! # Sets status to 0
post.draft? # => true
post.published! # Sets status to 1
Post.published # Scope: where(status: 1)
Stored as integers, accessed as symbols. This also plays well with PostgreSQL native enums if you prefer database-level type safety.
Counter Caches
class Post < ApplicationRecord
belongs_to :user, counter_cache: true
end
# Add column: add_column :users, :posts_count, :integer, default: 0
user.posts_count # Cached value, no COUNT query
When ActiveRecord Feels Wrong
You can’t see attributes in your model—that’s intentional. Check db/schema.rb or run User.column_names in the console. Some projects use the annotate gem to add schema comments directly in model files.
If you want strong typing, use RBS or Sorbet, but most Rails developers rely on tests instead.
Callbacks can hide logic. Use them sparingly and extract complex workflows to service objects.
The magic becomes predictable once you learn the conventions.
The shift from “explicitly declare everything” to “trust the database” feels uncomfortable at first. You’re used to TypeScript telling you exactly what properties exist. In Rails, you check the schema instead of the model file. This is “convention over configuration"—the philosophy that drives most Rails design decisions.
The payoff: change your database schema and your model interface updates instantly. Add a column in a migration, and every model instance gets getters, setters, and query methods for it. No updating type definitions. No regenerating schemas.
Use the Rails console constantly—rails console lets you experiment with queries, inspect objects, and see generated SQL with .to_sql. Check log/development.log to spot N+1 queries. Read db/schema.rb to understand your current database state. The Rails Guides on ActiveRecord cover everything else.