Constants #
Constants are Ruby’s way of storing values that shouldn’t change while the program runs — application configuration, mathematical values, lists of valid statuses, file size limits, and the like. But there’s something interesting and surprising about constants in Ruby: the interpreter doesn’t strictly forbid changing them. Ruby only issues a warning, then continues executing as if nothing happened. This means a “constant” in Ruby is better understood as a signal of intent to other programmers — not a technical guarantee that its value will never change. Understanding this nuance, along with how to create truly immutable constants, is the main topic of this article.
Defining Constants #
Constants in Ruby start with an uppercase letter. The community convention uses SCREAMING_SNAKE_CASE — all caps with underscores separating words — to distinguish them visually from variables and class names.
# Simple constants at the top level
PI = 3.14159265358979
GRAVITY = 9.80665 # m/s², Earth's standard gravity
SPEED_OF_LIGHT = 299_792_458 # meters per second
# Application configuration constants
APP_VERSION = "2.4.1"
APP_NAME = "Inventory Pro"
UPLOAD_LIMIT = 10 * 1024 * 1024 # 10 MB in bytes
TIMEOUT_SECONDS = 30
# Constants holding collections
VALID_STATUS = [:active, :inactive, :pending, :blocked].freeze
IMAGE_FORMATS = %w[jpg jpeg png webp gif].freeze
Class and module names are also constants — they just use PascalCase instead of SCREAMING_SNAKE_CASE.
# These are also constants — classes and modules are always constants in Ruby
class PaymentProcessor # PascalCase — a constant of class type
end
module AuthHelper # PascalCase — a constant of module type
end
# Check with constants — lists all defined constants
puts Object.constants.grep(/^[A-Z_]+$/).first(5).inspect
# => [:PI, :GRAVITY, :APP_VERSION, ...]
Reassignment Behavior — Warning, Not Error #
This is the most important part to understand: Ruby does not forbid constant reassignment. All it does is print a warning to stderr, then continue executing with the new value.
LIMIT = 100
puts LIMIT # => 100
LIMIT = 200 # warning: already initialized constant LIMIT
# warning: previous definition of LIMIT was here
puts LIMIT # => 200 (the change still succeeded!)
This means a “constant” in Ruby isn’t an immutability guarantee — it’s a communication convention. When you write MAX_LIMIT = 50, you’re telling other developers: “this value shouldn’t be changed, think twice before modifying it.”
# ANTI-PATTERN: changing a constant mid-program
HOLIDAY_DISCOUNT = 0.3
def calculate_discount(price)
price * HOLIDAY_DISCOUNT
end
# Elsewhere in the code:
HOLIDAY_DISCOUNT = 0.5 # ← warning, but still works — very dangerous!
# Now all already-running calculations use a different value
# CORRECT: if the value needs to change, use a variable or method, not a constant
def active_discount
ENV["DISCOUNT_PCT"]&.to_f || 0.3
end
If you run with the-W2option or enable verbose warnings, Ruby will show the exact location of both the original definition and the constant reassignment. In production environments, some frameworks like Rails configure Ruby to treat these warnings as serious signals. Don’t ignorealready initialized constantwarnings — they almost always indicate a bug or a design issue that needs fixing.
Mutation vs Reassignment #
There’s an important distinction that often confuses people: reassignment (replacing the object a constant refers to) produces a warning, but mutation (changing the contents of the referenced object) produces no warning at all.
DESTINATION_CITIES = ["Jakarta", "Surabaya", "Bandung"]
# Reassignment — warning
DESTINATION_CITIES = ["Medan", "Makassar"] # ← warning: already initialized constant
# Mutation — NO warning, but the constant's value changes!
DESTINATION_CITIES << "Yogyakarta" # ← no warning
DESTINATION_CITIES.push("Semarang") # ← no warning
puts DESTINATION_CITIES.inspect
# => ["Jakarta", "Surabaya", "Bandung", "Yogyakarta", "Semarang"]
This is why freeze is so important for constants that are collections (Array, Hash, String):
# ANTI-PATTERN: collection constant without freeze — can be silently mutated
ORDER_STATUS = ["pending", "processing", "shipped", "completed"]
ORDER_STATUS << "cancelled" # no warning, but this changes the "constant"!
# CORRECT: freeze prevents mutation
ORDER_STATUS = ["pending", "processing", "shipped", "completed"].freeze
ORDER_STATUS << "cancelled" # => FrozenError: can't modify frozen Array
# Hashes also need freezing
HTTP_CODES = {
ok: 200,
not_found: 404,
server_error: 500
}.freeze
HTTP_CODES[:created] = 201 # => FrozenError: can't modify frozen Hash
flowchart TD
A[Constant refers to an object] --> B{What are you doing?}
B --> C["Reassignment\nCONSTANT = new_value"]
B --> D["Mutation\nCONSTANT << element"]
C --> E["Ruby warning\n(still works)"]
D --> F{Is the object frozen?}
F --> G["Yes — freeze active\n→ FrozenError"]
F --> H["No — without freeze\n→ Succeeds without warning!"]
G --> I["✓ Constant protected"]
H --> J["✗ Value changes silently"]Deep Freeze for Nested Collections #
freeze only freezes the object at the surface — its elements can still be mutated if they’re mutable objects.
# Shallow freeze — string elements inside the array can still be mutated
DAY_NAMES = ["Monday", "Tuesday", "Wednesday"].freeze
DAY_NAMES << "Thursday" # => FrozenError: the array is frozen
DAY_NAMES[0] << " Morning" # SUCCEEDS! The String inside isn't frozen
puts DAY_NAMES[0] # => "Monday Morning" ← value changed!
# CORRECT: freeze each element too
DAY_NAMES = ["Monday".freeze, "Tuesday".freeze, "Wednesday".freeze].freeze
DAY_NAMES[0] << " Morning" # => FrozenError: the string is frozen too
# A more concise way using map:
DAY_NAMES = ["Monday", "Tuesday", "Wednesday"].map(&:freeze).freeze
# Or with the magic comment — freezes all string literals in the file:
# frozen_string_literal: true
DAY_NAMES = ["Monday", "Tuesday", "Wednesday"].freeze
DAY_NAMES[0] << " Morning" # => FrozenError because of frozen_string_literal
Constants in Classes and Modules #
Defining constants inside a class or module is a very common and recommended practice. It provides namespacing — constants are bound to their relevant context instead of floating in the global scope.
class DatabaseConfig
HOST = ENV.fetch("DB_HOST", "localhost")
PORT = ENV.fetch("DB_PORT", "5432").to_i
DB_NAME = ENV.fetch("DB_NAME", "app_development")
POOL_SIZE = 10
TIMEOUT = 5_000 # milliseconds
def self.connection_string
"postgresql://#{HOST}:#{PORT}/#{DB_NAME}"
end
end
puts DatabaseConfig::HOST # => "localhost"
puts DatabaseConfig::PORT # => 5432
puts DatabaseConfig.connection_string
# => "postgresql://localhost:5432/app_development"
module InputValidation
MIN_PASSWORD_LENGTH = 8
MAX_PASSWORD_LENGTH = 128
EMAIL_PATTERN = /\A[\w+\-.]+@[a-z\d\-.]+\.[a-z]+\z/i.freeze
PHONE_PATTERN = /\A(\+62|0)[0-9]{9,12}\z/.freeze
SPECIAL_CHARS = %w[! @ # $ % ^ & * ( )].freeze
def self.email_valid?(email)
email.match?(EMAIL_PATTERN)
end
def self.password_valid?(password)
password.length.between?(MIN_PASSWORD_LENGTH, MAX_PASSWORD_LENGTH)
end
end
puts InputValidation.email_valid?("[email protected]") # => true
puts InputValidation.email_valid?("not-an-email") # => false
puts InputValidation::MIN_PASSWORD_LENGTH # => 8
Constants in Subclasses #
Constants are inherited by subclasses — a subclass can access its parent class’s constants. But a subclass can also redefine a constant with its own value without affecting the parent class.
class Animal
BIOLOGICAL_KINGDOM = "Animalia"
NORMAL_TEMP = 37.0 # Celsius, mammalian average
def basic_info
"Kingdom: #{BIOLOGICAL_KINGDOM}, Normal temp: #{NORMAL_TEMP}°C"
end
end
class Reptile < Animal
NORMAL_TEMP = nil # reptiles are cold-blooded — override the parent constant
def basic_info
# BIOLOGICAL_KINGDOM is inherited from Animal
"Kingdom: #{BIOLOGICAL_KINGDOM}, Cold-blooded"
end
end
puts Animal.new.basic_info # => Kingdom: Animalia, Normal temp: 37.0°C
puts Reptile.new.basic_info # => Kingdom: Animalia, Cold-blooded
puts Animal::NORMAL_TEMP # => 37.0 (unchanged)
puts Reptile::NORMAL_TEMP # => nil
The Constant Lookup Path #
When Ruby encounters a name starting with an uppercase letter in code, it searches for that constant through the constant lookup path — a sequence of scopes checked one by one.
LEVEL = "global"
module Outer
LEVEL = "outer module"
module Inner
LEVEL = "inner module"
def self.print_level
puts LEVEL # => "inner module" — found in the nearest scope
end
end
def self.print_level
puts LEVEL # => "outer module"
puts Inner::LEVEL # => "inner module" — explicit access
puts ::LEVEL # => "global" — :: forces a top-level lookup
end
end
Outer.print_level
Outer::Inner.print_level
flowchart TD
A["Ruby encounters the name CONSTANT"] --> B["Search in the current\nclass/module scope"]
B --> C{Found?}
C --> |Yes| Z["Use this value"]
C --> |No| D["Search in included/\nextended modules"]
D --> E{Found?}
E --> |Yes| Z
E --> |No| F["Search in parent\nclasses (ancestor chain)"]
F --> G{Found?}
G --> |Yes| Z
G --> |No| H["Search at the top level\n(Object)"]
H --> I{Found?}
I --> |Yes| Z
I --> |No| J["NameError: uninitialized\nconstant CONSTANT"]The :: operator is used for explicit namespace navigation:
# Access constants from a specific namespace
puts Math::PI # => 3.141592653589793
puts Math::E # => 2.718281828459045
# :: at the start means top-level
module App
VERSION = "1.0"
class Config
VERSION = "config-1.0"
def app_version
::App::VERSION # access App::VERSION from inside Config
end
def config_version
VERSION # local constant — Config::VERSION
end
end
end
c = App::Config.new
puts c.app_version # => "1.0"
puts c.config_version # => "config-1.0"
Constant Usage Patterns in Real Applications #
Here are some common constant patterns you’ll encounter in professional Ruby and Rails codebases.
Manual Enums with Hashes #
Before Ruby had a built-in enum, developers used constant Hashes to define limited value sets.
module OrderStatus
PENDING = "pending"
PROCESSING = "processing"
SHIPPED = "shipped"
COMPLETED = "completed"
CANCELLED = "cancelled"
ALL = [PENDING, PROCESSING, SHIPPED, COMPLETED, CANCELLED].freeze
CAN_BE_CANCELLED = [PENDING, PROCESSING].freeze
IS_FINAL = [COMPLETED, CANCELLED].freeze
def self.valid?(status)
ALL.include?(status)
end
def self.cancellable?(status)
CAN_BE_CANCELLED.include?(status)
end
end
order_status = "shipped"
puts OrderStatus.valid?(order_status) # => true
puts OrderStatus.cancellable?(order_status) # => false
# Usage in other code — safer than string literals
if order.status == OrderStatus::COMPLETED
send_confirmation_email(order)
end
Nested Configuration with Modules #
module Config
module Database
ADAPTER = "postgresql"
POOL = Integer(ENV.fetch("DB_POOL", 5))
TIMEOUT = 5_000
end
module Cache
DRIVER = :redis
TTL = 3_600 # 1 hour in seconds
MAX_SIZE = 256 * 1024 * 1024 # 256 MB
end
module Upload
MAX_SIZE = 10 * 1024 * 1024 # 10 MB
ALLOWED_TYPES = %w[jpg jpeg png pdf docx].freeze
TEMP_PATH = "/tmp/uploads".freeze
end
end
puts Config::Database::POOL # => 5
puts Config::Upload::MAX_SIZE # => 10485760
puts Config::Upload::ALLOWED_TYPES.inspect
# => ["jpg", "jpeg", "png", "pdf", "docx"]
Environment-Based Constants #
Constants whose values are read from environment variables are a common pattern for configuration that differs between environments (development, staging, production).
module AppConfig
# ENV.fetch raises KeyError if the variable is missing (safer than ENV[])
SECRET_KEY = ENV.fetch("SECRET_KEY_BASE") { raise "SECRET_KEY_BASE must be set!" }
DATABASE_URL = ENV.fetch("DATABASE_URL", "postgresql://localhost/app_dev")
# Boolean values from the environment
DEBUG_MODE = ENV.fetch("DEBUG", "false") == "true"
MAINTENANCE = ENV.fetch("MAINTENANCE_MODE", "false") == "true"
# Numeric values — always convert types explicitly
MAX_WORKERS = Integer(ENV.fetch("MAX_WORKERS", 4))
REQUEST_TIMEOUT = Float(ENV.fetch("REQUEST_TIMEOUT", 30.0))
end
# ANTI-PATTERN: using ENV[] directly all over the code
# (no single point of control, no type guarantees)
def send_email
smtp_host = ENV["SMTP_HOST"] # could be nil anytime, String or nil type
# ...
end
# CORRECT: centralize in constants with guaranteed types
module Mailer
SMTP_HOST = ENV.fetch("SMTP_HOST", "localhost").freeze
SMTP_PORT = Integer(ENV.fetch("SMTP_PORT", 587))
FROM_ADDR = ENV.fetch("MAIL_FROM", "[email protected]").freeze
end
def send_email
smtp_host = Mailer::SMTP_HOST # always a String, never nil
# ...
end
Constant Naming Conventions #
Ruby uses two styles for constants, and both have their rightful place.
| Style | When to use | Example |
|---|---|---|
SCREAMING_SNAKE_CASE | Configuration values, numbers, strings, collections | MAX_SIZE, DEFAULT_TIMEOUT, ACTIVE_STATUS |
PascalCase | Class, module, and type names | UserService, AuthHelper, HttpClient |
# SCREAMING_SNAKE_CASE — for configuration values and literals
MAX_ATTEMPTS = 3
PAGE_LIMIT = 25
EXTERNAL_API_URL = "https://api.example.com/v2".freeze
# PascalCase — for classes and modules (also constants!)
class PaymentManager
ACTIVE_PROVIDER = :midtrans.freeze # config constant inside a class
end
module FormattingUtils
DEFAULT_LOCALE = :id.freeze
end
# ANTI-PATTERN: inconsistent styles
maxSize = 10 # ← this isn't a constant, it's a local variable!
Max_Size = 10 # ← valid but not idiomatic
MAXSIZE = 10 # ← valid but hard to read
Constant Introspection #
Ruby provides several methods to explore defined constants at runtime — useful for debugging and metaprogramming.
module Configuration
VERSION = "1.0"
DEBUG = false
TIMEOUT = 30
end
# List all constants in a module
puts Configuration.constants.inspect
# => [:VERSION, :DEBUG, :TIMEOUT]
# Fetch a constant's value dynamically
constant_name = :VERSION
puts Configuration.const_get(constant_name) # => "1.0"
# Check whether a constant exists
puts Configuration.const_defined?(:VERSION) # => true
puts Configuration.const_defined?(:NOT_THERE) # => false
# Set a constant dynamically (use with caution)
Configuration.const_set(:ENVIRONMENT, "production")
puts Configuration::ENVIRONMENT # => "production"
When to use constant introspection:
✓ Loading configuration dynamically from a file or database
✓ Metaprogramming — generating classes or constants automatically
✓ Testing — verifying expected constants exist
✓ Debugging — seeing all constants defined in a module
✗ Don't use const_set to change an already-existing constant
✗ Avoid const_get with user input — potential security issue
Summary #
- Constants start with an uppercase letter —
SCREAMING_SNAKE_CASEfor configuration values,PascalCasefor classes and modules.- Ruby doesn’t forbid reassignment — it only warns — a constant is a signal of intent, not a technical guarantee. Don’t ignore
already initialized constantwarnings.- Mutation is different from reassignment — changing the contents of a constant collection produces no warning, so it can happen silently without detection.
- Always
freezecollection constants — Arrays, Hashes, and Strings used as constant values should befreezed to prevent accidental mutation.freezeis only shallow — for nested collections, also freeze each element inside, or enable# frozen_string_literal: true.- Define constants inside classes or modules — this provides clear namespacing and prevents global scope pollution.
::for explicit namespace navigation —App::Config::TIMEOUTis clearer than implicit access relying on the lookup path.- Centralize environment configuration — read
ENVin one place and store it as clearly typed constants, instead of callingENV[]all over the place.const_getandconst_defined?for dynamically introspecting constants when needed in metaprogramming or testing.