What Is Metaprogramming? Pros and Cons of Using Metaprogramming

Ruby / Ruby on Rails
Oleksandr Vykhor
87
25-09-2026 15:32:11


What Is Metaprogramming? Pros and Cons of Using Metaprogramming

If you have ever written has_many :comments in a Rails model and suddenly got article.comments, article.comments.create and a dozen other methods for free, you have already used metaprogramming. It is one of the most powerful features of Ruby and one of the most controversial. In this article we will look at what metaprogramming is, which tools Ruby offers for it, how Rails uses it, and what its advantages and drawbacks are.

What is metaprogramming?

Metaprogramming is writing code that writes, reads or changes other code (or itself). A regular program works with data: numbers, strings, users, orders. A metaprogram works with the program itself: it creates classes and methods, inspects their structure, and changes their behavior while the application is running.

There are two main kinds of metaprogramming:

  • Compile-time: code is generated before the program runs. Examples: macros in C and Rust, templates in C++, code generators.
  • Runtime: the program changes itself while running. This is the Ruby way: classes stay open, methods can be defined on the fly, and any object can be asked what it can do.

Why Ruby is ideal for metaprogramming

  • Everything is an object, including classes and modules. A class is just an instance of Class, so you can call methods on it and change it.
  • Classes are open. You can reopen any class, even a built-in one like String, and add methods to it.
  • Class bodies are executable code. attr_accessor :name is not special syntax, it is an ordinary method call that runs while the class is being defined.
  • Rich reflection API. Objects can tell you their methods, instance variables, ancestors and where each method was defined.

Main metaprogramming tools in Ruby

define_method: creating methods dynamically

Let us write our own version of attr_accessor:

module SimpleAttributes
  def simple_attr(*names)
    names.each do |name|
      define_method(name) { instance_variable_get("@#{name}") }
      define_method("#{name}=") { |value| instance_variable_set("@#{name}", value) }
    end
  end
end

class User
  extend SimpleAttributes
  simple_attr :name, :email
end

user = User.new
user.name = "Anna"
puts user.name # => "Anna"

A more practical example is generating predicate methods for statuses instead of writing each one by hand:

class Order
  STATUSES = %w[pending paid shipped cancelled].freeze

  attr_reader :status

  def initialize(status)
    @status = status
  end

  STATUSES.each do |s|
    define_method("#{s}?") { status == s }
  end
end

order = Order.new("paid")
order.paid?    # => true
order.shipped? # => false

Adding a new status now means adding one word to the array.

send and public_send: calling methods by name

%w[daily weekly monthly].each do |period|
  report.public_send("generate_#{period}")
end

send can call private methods too, while public_send respects visibility. Prefer public_send unless you really need to reach a private method.

method_missing: handling calls to methods that do not exist

When Ruby cannot find a method, it calls method_missing. By overriding it, you can respond to methods that were never defined:

class Settings
  def initialize(data)
    @data = data
  end

  def method_missing(name, *args)
    key = name.to_s
    return @data[key] if @data.key?(key)

    super
  end

  def respond_to_missing?(name, include_private = false)
    @data.key?(name.to_s) || super
  end
end

settings = Settings.new("host" => "localhost", "port" => 5432)
settings.host               # => "localhost"
settings.respond_to?(:port) # => true

Two rules here: always call super for names you do not handle (otherwise typos will silently return nil), and always define respond_to_missing? so that respond_to? and method(...) work correctly.

class_eval, instance_eval and hooks

  • class_eval runs code in the context of a class, for example to add methods to it from the outside.
  • instance_eval runs code in the context of a specific object. It is the foundation of many DSLs, such as configuration blocks.
  • Hooks such as included, inherited and method_added let a module or class react when it is included, subclassed or extended.

Refinements: safer monkey patching

Instead of changing a built-in class globally, you can limit the change to one file or module:

module StringExtensions
  refine String do
    def shout
      upcase + "!"
    end
  end
end

using StringExtensions
"hello".shout # => "HELLO!"

Metaprogramming in Rails

Rails is probably the best-known example of metaprogramming in action. Look at an ordinary model:

class Article < ApplicationRecord
  belongs_to :author
  has_many :comments, dependent: :destroy

  enum :status, { draft: 0, published: 1, archived: 2 }

  validates :title, presence: true, length: { maximum: 200 }
  scope :recent, -> { order(created_at: :desc) }
  delegate :name, to: :author, prefix: true
end

Every line here is a method call that generates code:

  • Attributes such as title and title= are created from the database schema. They are not written anywhere in the model.
  • has_many :comments adds comments, comments=, comment_ids and more.
  • enum generates published?, published! and the scopes Article.published, Article.draft.
  • scope :recent defines the class method Article.recent.
  • delegate creates article.author_name.
  • ActiveSupport::Concern uses the included hook to add callbacks and class methods to models.

Thanks to this, a model that is ten lines long can offer hundreds of methods. That is the power of metaprogramming, and also the reason newcomers often ask: “Where is this method defined?”

Advantages of metaprogramming

  1. Less repetitive code (DRY). One loop with define_method replaces dozens of nearly identical methods.
  2. Expressive DSLs. Routes in Rails, specs in RSpec, migrations and Rake tasks read almost like plain English.
  3. Flexibility. Code can adapt to data that is known only at runtime, such as database columns, configuration or an external API schema.
  4. Powerful frameworks and libraries. ActiveRecord, RSpec, Devise and many other gems are built on metaprogramming, and they save developers enormous amounts of time.
  5. Easier extension. Adding a new status, field or handler often means adding one item to a list rather than writing new methods.

Disadvantages of metaprogramming

  1. “Magic” and poor readability. When methods are generated on the fly, you cannot see them in the code. A new developer has to understand the mechanism before understanding the class.
  2. Hard to navigate. Searching the project for def paid? finds nothing, because the method was created by define_method. IDE “go to definition” often fails too.
  3. Harder debugging. Stack traces go through method_missing, define_method blocks and library internals, which makes it harder to find the real source of an error.
  4. Weaker static analysis. Linters, type checkers (Sorbet, RBS/Steep) and autocompletion cannot always see dynamically created methods.
  5. Performance. method_missing is slower than a regular method call, because Ruby first searches the entire ancestor chain. eval with strings has to parse code at runtime.
  6. Security risks. Calling send or eval with user input can let an attacker run any method or any code.
  7. Conflicts from monkey patching. Two gems that add a method with the same name to String or Hash will quietly overwrite each other.

An example of the security problem and how to fix it:

# Dangerous: the user decides which method is called
record.send(params[:operation])

# Safe: only allow-listed methods can be called
ALLOWED_OPERATIONS = %w[archive publish].freeze

operation = params[:operation]
record.public_send(operation) if ALLOWED_OPERATIONS.include?(operation)

Best practices

  1. Start with plain code. Use metaprogramming only when it removes real duplication, not just because it looks clever. A good rule is to wait until the same pattern appears at least three times.
  2. Prefer define_method over method_missing. Defined methods are faster, visible to reflection and easier to debug.
  3. If you use method_missing, always call super and implement respond_to_missing?.
  4. Use public_send instead of send and never pass unchecked user input to either of them.
  5. Avoid eval with strings. Blocks with define_method and class_eval are almost always enough.
  6. Use refinements or modules with prepend instead of global monkey patching of built-in classes.
  7. Document generated methods with comments or YARD tags such as @!method, so people and tools know they exist.
  8. Keep the magic in libraries and infrastructure, not in business logic. Framework code can be clever; domain code should be obvious.
  9. Cover generated behavior with tests. It is easy to break something you cannot see.

Ruby also helps you find “invisible” methods when debugging:

order = Order.new("paid")

order.method(:paid?).source_location # => ["app/models/order.rb", 11]
Order.instance_methods(false)        # => [:status, :pending?, :paid?, ...]
Article.instance_method(:author_name).owner

Conclusion

Metaprogramming is code that writes code. In Ruby it is part of everyday work: every attr_accessor, has_many and validates is metaprogramming. It helps remove duplication, build elegant DSLs and create frameworks like Rails. The price is less explicit code, harder debugging and potential performance and security issues. Use metaprogramming deliberately: when it truly makes the code simpler for the people who will read it, not just shorter.


Back