A JavaScript developer's guide to Rails: ActiveRecord Fundamentals

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.

About thoughtbot

We've been helping engineering teams deliver exceptional products for over 20 years. Our designers, developers, and product managers work closely with teams to solve your toughest software challenges through collaborative design and development. Learn more about us.