Core Syntax #
Ruby was designed with one guiding philosophy: code should be easy for humans to read, not just for machines. The result is a language whose syntax is extremely expressive — you can write code that almost feels like reading a sentence in English. But this expressiveness doesn’t mean there are no rules. Ruby has fairly strict conventions about how code should be written, and understanding these conventions from the start is the key to writing idiomatic Ruby. This article covers all the foundations of Ruby syntax — from variables to error handling — with an emphasis on the writing style the Ruby community considers correct.
Variables and Scope #
One of the first things that makes Ruby different from many other languages is how it marks variable types with specific prefixes. There’s no type declaration like int or string — but Ruby has its own way of distinguishing where a variable can be accessed.
flowchart TD
A[Ruby Variables] --> B["No prefix\nLocal Variable"]
A --> C["@prefix\nInstance Variable"]
A --> D["@@prefix\nClass Variable"]
A --> E["$prefix\nGlobal Variable"]
B --> B1["Only within the\ncurrent method/block"]
C --> C1["Within the entire\nobject instance"]
D --> D1["Shared across all\nclass instances"]
E --> E1["Anywhere in\nthe program"]Local Variables #
Local variables start with a lowercase letter or underscore (_). Their scope is limited to the block of code where they’re defined — if you define one inside a method, it can’t be accessed from another method.
def calculate_total(price, quantity)
discount = 0.1 # local variable
subtotal = price * quantity # local variable
total = subtotal - (subtotal * discount)
total
end
# ANTI-PATTERN: trying to access a local variable from outside its scope
puts discount # => NameError: undefined local variable or method 'discount'
# CORRECT: local variables only live inside their method
puts calculate_total(100_000, 3) # => 270000.0
Ruby’s naming convention for local variables is snake_case — all lowercase with underscores separating words. This isn’t just a style choice; the Ruby community considers camelCase for local variables unidiomatic code.
# ANTI-PATTERN: camelCase for local variables
firstName = "Budi"
totalHarga = 50_000
# CORRECT: snake_case
first_name = "Budi"
total_harga = 50_000
Instance Variables #
Instance variables start with @ and live as long as the object of that class lives. All methods within one instance can access them, but different instances have different @ values.
class Student
def initialize(name, nim)
@name = name # instance variable
@nim = nim # instance variable
end
def introduction
# @name is accessible here because it's the same instance
"Hello, I'm #{@name} with NIM #{@nim}."
end
def change_name(new_name)
@name = new_name
end
end
student1 = Student.new("Rina", "20230001")
student2 = Student.new("Bagas", "20230002")
puts student1.introduction # => Hello, I'm Rina with NIM 20230001.
puts student2.introduction # => Hello, I'm Bagas with NIM 20230002.
student1.change_name("Rina Wijaya")
puts student1.introduction # => Hello, I'm Rina Wijaya with NIM 20230001.
puts student2.introduction # => Hello, I'm Bagas with NIM 20230002. (unaffected)
Class Variables #
Class variables start with @@ and share their value across all instances of the same class. This is useful for tracking state that applies to the whole class, not per-object.
class Connection
@@connection_count = 0 # class variable, shared across all instances
def initialize(host)
@host = host
@@connection_count += 1
end
def self.active_count
@@connection_count
end
end
c1 = Connection.new("db-primary")
c2 = Connection.new("db-replica")
c3 = Connection.new("cache")
puts Connection.active_count # => 3
Class variables (@@) are inherited by subclasses and can cause bugs that are hard to track down. If a subclass changes the value of@@, that change is also visible in the parent class. In modern code, many Ruby developers prefer using class-level instance variables (@variableinsideself.method) over@@variableto avoid this issue.
Global Variables #
Global variables start with $ and can be accessed from anywhere in the program. That sounds practical, but it’s almost always a bad choice.
# ANTI-PATTERN: global variables make code hard to predict
$debug_mode = true
def process_data(data)
puts "[DEBUG] Processing: #{data}" if $debug_mode
# other code...
end
# CORRECT: use parameters or constants instead
DEBUG_MODE = ENV["DEBUG"] == "true" # read from the environment
def process_data(data, verbose: false)
puts "[DEBUG] Processing: #{data}" if verbose
# other code...
end
When global variables are acceptable:
✓ Ruby built-ins like $stdin, $stdout, $stderr
✓ $0 for the name of the currently running program file
✓ $LOAD_PATH for the library search path
✗ Your own global variables for storing application state
✗ As a "shortcut" because you're too lazy to pass parameters
Data Types #
Ruby is dynamically typed — you don’t need to declare variable types. But every value in Ruby is an object, and every object has its own type. Understanding Ruby’s basic data types is a foundation you can’t skip.
Strings #
Strings in Ruby can be written with single quotes (') or double quotes ("). The difference isn’t just cosmetic.
name = "Dewi"
city = 'Jakarta'
# Double quotes: support string interpolation and escape sequences
puts "Hello, #{name}! You're from #{city}." # => Hello, Dewi! You're from Jakarta.
puts "New line:\nThis is the second line." # => newline active
# Single quotes: literal, no interpolation or escaping
puts 'Hello, #{name}!' # => Hello, #{name}! (printed as-is)
puts 'New line:\n...' # => New line:\n... (\n not processed)
# ANTI-PATTERN: string concatenation with +
message = "Welcome, " + name + "! You're from " + city + "."
# CORRECT: use interpolation — cleaner and faster
message = "Welcome, #{name}! You're from #{city}."
Some commonly used String methods:
text = " Ruby is a fun language! "
text.strip # => "Ruby is a fun language!"
text.upcase # => " RUBY IS A FUN LANGUAGE! "
text.downcase # => " ruby is a fun language! "
text.include?("Ruby") # => true
text.split(" ") # => ["Ruby", "is", "a", "fun", "language!"]
text.gsub("Ruby", "Python") # => " Python is a fun language! "
Integers and Floats #
Ruby distinguishes whole numbers (Integer) from decimal numbers (Float). Operations between two Integers produce an Integer — including division.
# ANTI-PATTERN: integer division produces a surprising result
puts 7 / 2 # => 3 (not 3.5!)
puts 7 % 2 # => 1 (remainder)
# CORRECT: use Float if you need a decimal result
puts 7.0 / 2 # => 3.5
puts 7 / 2.0 # => 3.5
puts 7.fdiv(2) # => 3.5 (the idiomatic Ruby way)
Ruby supports underscores as thousands separators for readability:
indonesia_population = 270_000_000 # far easier to read than 270000000
house_price = 1_500_000_000
pi_approx = 3.141_592_653
Symbols #
Symbols are a data type that often confuses beginners. A Symbol looks like a String but isn’t the same — a Symbol is an immutable identifier that always refers to the same object in memory.
# Symbol: starts with a colon
status = :active
role = :admin
# ANTI-PATTERN: using strings as hash keys
user = {"name" => "Andi", "role" => "admin"}
# CORRECT: use symbols as hash keys — more efficient
user = {name: "Andi", role: :admin}
puts user[:name] # => Andi
# Symbols are singletons — the same object in memory
puts :active.object_id == :active.object_id # => true
puts "active".object_id == "active".object_id # => false (two different objects)
Arrays #
An Array is an ordered collection that can hold objects of any type.
# Creating arrays
fruits = ["apple", "mango", "orange"]
numbers = [1, 2, 3, 4, 5]
mixed = [1, "two", :three, 4.0, true]
# Accessing elements
puts fruits[0] # => apple
puts fruits[-1] # => orange (negative index counts from the end)
puts fruits[1..2] # => ["mango", "orange"] (range)
# Common methods
fruits.push("pineapple") # add to the end
fruits.unshift("durian") # add to the front
fruits.pop # remove and return the last element
fruits.length # => number of elements
# Iteration — the idiomatic Ruby way
fruits.each { |f| puts f }
fruits.map { |f| f.upcase } # => ["APPLE", "MANGO", "ORANGE"]
fruits.select { |f| f.length > 4 } # => ["mango", "orange"]
fruits.reject { |f| f.start_with?("a") } # => ["mango", "orange"]
Hashes #
A Hash is a collection of key-value pairs, similar to a dictionary in Python or an object in JavaScript.
# Hash with symbol keys (the modern way)
profile = {
name: "Siti",
age: 28,
city: "Bandung",
active: true
}
puts profile[:name] # => Siti
puts profile[:age] # => 28
# Add or change keys
profile[:email] = "[email protected]"
profile[:age] = 29
# Check key existence
profile.key?(:email) # => true
profile.key?(:phone) # => false
# Iteration
profile.each do |key, value|
puts "#{key}: #{value}"
end
# Transformation
profile.map { |k, v| [k, v.to_s] }.to_h # all values become strings
profile.select { |k, v| v.is_a?(String) } # only String values
Operators #
Ruby supports all the standard operators, plus a few unique ones that make it extremely expressive.
Arithmetic and Comparison Operators #
# Arithmetic
puts 10 + 3 # => 13
puts 10 - 3 # => 7
puts 10 * 3 # => 30
puts 10 / 3 # => 3 (integer division)
puts 10 % 3 # => 1 (modulo)
puts 10 ** 3 # => 1000 (power)
# Comparison
puts 10 > 3 # => true
puts 10 < 3 # => false
puts 10 >= 10 # => true
puts 10 == 10 # => true
puts 10 != 3 # => true
The Spaceship Operator (<=>) #
Ruby has a unique operator called the spaceship operator — very useful for sorting.
puts 1 <=> 2 # => -1 (left is smaller)
puts 2 <=> 2 # => 0 (equal)
puts 3 <=> 2 # => 1 (left is greater)
# Practical use: sort with custom rules
names = ["Citra", "Andi", "Bagas", "Devi"]
names.sort { |a, b| a <=> b } # => ["Andi", "Bagas", "Citra", "Devi"]
names.sort { |a, b| b <=> a } # => ["Devi", "Citra", "Bagas", "Andi"] (reversed)
The Safe Navigation Operator (&.) #
The &. operator (called the lonely operator) prevents NoMethodError when calling a method on a value that might be nil.
# ANTI-PATTERN: vulnerable to NoMethodError if user is nil
def display_email(user)
puts user.email.upcase
end
# CORRECT: use the safe navigation operator
def display_email(user)
puts user&.email&.upcase || "Email not available"
end
Logical Operators #
# && and || (stricter, recommended for conditions)
puts true && false # => false
puts true || false # => true
puts !true # => false
# and, or, not (looser, avoid in condition expressions)
# Use 'and' and 'or' only for flow control, not boolean expressions
# ANTI-PATTERN: and/or in conditions can surprise you
value = true and false # value = true (not false!)
value = true && false # value = false (as expected)
Control Flow #
Ruby offers many ways to control program flow. What sets Ruby apart is that many of these constructs can be written in a very compact form.
Conditionals #
score = 75
# Standard form
if score >= 80
puts "Passed with a good grade"
elsif score >= 60
puts "Passed"
else
puts "Failed"
end
# Inline if — for simple one-line conditions
puts "Passed" if score >= 60
puts "Failed" unless score >= 60 # unless = if not
# Ternary — for conditional assignment
status = score >= 60 ? "Passed" : "Failed"
# ANTI-PATTERN: nested ternary — hard to read
result = a > b ? (a > c ? a : c) : (b > c ? b : c)
# CORRECT: use if-elsif for nested conditions
result = if a > b && a > c
a
elsif b > c
b
else
c
end
Case/When #
case in Ruby is far more powerful than switch in other languages — it can match by class, range, regex, and even custom conditions.
score = 85
# Matching with ranges
grade = case score
when 90..100 then "A"
when 80..89 then "B"
when 70..79 then "C"
when 60..69 then "D"
else "E"
end
puts grade # => B
# Matching with classes
def describe(object)
case object
when String then "This is a String: #{object}"
when Integer then "This is an Integer: #{object}"
when Array then "This is an Array with #{object.length} elements"
when NilClass then "This is nil"
else "Unknown type: #{object.class}"
end
end
puts describe("Ruby") # => This is a String: Ruby
puts describe(42) # => This is an Integer: 42
puts describe([1, 2, 3]) # => This is an Array with 3 elements
Loops #
Ruby has many ways to loop. The most idiomatic is using Enumerable iterators, not for or while.
# ANTI-PATTERN: C-style for loop
for i in 0..4
puts i
end
# CORRECT: use .each or .times
5.times { |i| puts i }
(0..4).each { |i| puts i }
# Iterating over an array
products = ["laptop", "mouse", "keyboard"]
# ANTI-PATTERN: manual index access
i = 0
while i < products.length
puts products[i]
i += 1
end
# CORRECT: each
products.each { |p| puts p }
# If you need the index along with the value:
products.each_with_index do |p, i|
puts "#{i + 1}. #{p}"
end
# => 1. laptop
# => 2. mouse
# => 3. keyboard
# Looping with while and until
count = 0
while count < 5
count += 1
end
count = 0
until count == 5
count += 1
end
# loop with break — for infinite loops
loop do
input = gets.chomp
break if input == "exit"
puts "You typed: #{input}"
end
Blocks, Procs, and Lambdas #
Blocks, Procs, and Lambdas are three ways Ruby represents chunks of code that can be passed around and called later. Understanding their differences is one of the most important parts of learning Ruby.
flowchart TD
A[Passable Code Chunk] --> B[Block]
A --> C[Proc]
A --> D[Lambda]
B --> B1["Can't be stored\nin a variable"]
B --> B2["Passed directly\nto a method"]
C --> C1["Can be stored\nin a variable"]
C --> C2["Return exits the\ncalling method"]
D --> D1["Can be stored\nin a variable"]
D --> D2["Return only exits\nthe lambda itself"]
D --> D3["Strictly checks\nthe argument count"]Blocks #
A block is a segment of code passed to a method. Blocks can’t be stored in a variable — they can only be used directly when calling a method.
# Block syntax: do...end or { }
# Use do...end for multi-line blocks:
[1, 2, 3].each do |n|
square = n ** 2
puts "#{n}^2 = #{square}"
end
# Use { } for one-line blocks:
[1, 2, 3].map { |n| n ** 2 } # => [1, 4, 9]
# Creating a method that accepts a block:
def run_twice
yield # call the passed block
yield
end
run_twice { puts "Hello!" }
# => Hello!
# => Hello!
# Passing values to the block via yield:
def greet(name)
yield name if block_given?
end
greet("Dewi") { |n| puts "Welcome, #{n}!" }
# => Welcome, Dewi!
Procs #
A Proc is a block stored as an object. This is useful when you want to save the same logic for repeated use.
positive_check = Proc.new { |n| n > 0 }
puts positive_check.call(5) # => true
puts positive_check.call(-3) # => false
# Procs can be passed to methods with &
numbers = [-2, -1, 0, 1, 2, 3]
positive = numbers.select(&positive_check)
puts positive.inspect # => [1, 2, 3]
# IMPORTANT: return inside a Proc exits the calling method
def check_value
validation = Proc.new { return "exiting the method" }
validation.call
puts "This line will never execute" # never reached
end
puts check_value # => exiting the method
Lambdas #
A Lambda is a Proc with two key differences: it strictly checks the argument count, and return inside a Lambda only exits the Lambda itself (not the calling method).
# Lambda syntax
multiply = ->(a, b) { a * b }
greet = lambda { |name| "Hello, #{name}!" }
puts multiply.call(4, 5) # => 20
puts greet.call("Rudi") # => Hello, Rudi!
# Lambdas are strict about arguments
multiply.call(4) # => ArgumentError: wrong number of arguments (given 1, expected 2)
# CORRECT: return from a lambda doesn't exit the method
def check_lambda
calculate = ->(n) { return n * 2 }
result = calculate.call(10)
puts "Result: #{result}" # this line STILL executes
end
check_lambda # => Result: 20
| Block | Proc | Lambda | |
|---|---|---|---|
| Store in a variable | ✗ | ✓ | ✓ |
| Checks argument count | No | No | Yes (strict) |
| Return behavior | Exits the method | Exits the method | Exits the lambda |
| How to create | do..end / {} | Proc.new {} | -> {} / lambda {} |
Special Methods #
Ruby has a few special methods and keywords you need to understand to write good classes.
attr_reader, attr_writer, attr_accessor #
Instead of writing getters and setters by hand, Ruby provides shortcuts through attr_*.
# ANTI-PATTERN: verbose manual getters and setters
class Product
def name
@name
end
def name=(value)
@name = value
end
def price
@price
end
end
# CORRECT: use attr_accessor
class Product
attr_reader :id # read-only
attr_writer :stock # write-only
attr_accessor :name, :price # read and write
def initialize(id, name, price)
@id = id
@name = name
@price = price
@stock = 0
end
def info
"#{@name} - Rp #{@price}"
end
end
p = Product.new(1, "Keyboard", 350_000)
puts p.name # => Keyboard
puts p.price # => 350000
p.name = "Mechanical Keyboard"
p.price = 500_000
puts p.info # => Mechanical Keyboard - Rp 500000
Methods with Default and Keyword Arguments #
# Default arguments
def send_email(to, subject = "No Subject", format = :html)
puts "Send to #{to} | Subject: #{subject} | Format: #{format}"
end
send_email("[email protected]")
send_email("[email protected]", "Welcome")
# Keyword arguments — more explicit and order doesn't matter
def create_user(name:, email:, role: :user, active: true)
puts "User: #{name} (#{email}) | Role: #{role} | Active: #{active}"
end
create_user(name: "Rina", email: "[email protected]")
create_user(email: "[email protected]", name: "Admin", role: :admin)
Error Handling #
Ruby uses the begin...rescue...ensure mechanism to handle exceptions. Understanding how to handle errors properly is the difference between fragile and robust code.
sequenceDiagram
participant Program
participant Begin as begin block
participant Rescue as rescue block
participant Ensure as ensure block
Program->>Begin: Run code
alt No error
Begin-->>Ensure: Normal completion
else Error occurred
Begin->>Rescue: Raise exception
Rescue-->>Ensure: Handle error
end
Ensure-->>Program: Always runsdef divide(a, b)
begin
result = a / b
puts "Result: #{result}"
rescue ZeroDivisionError => e
puts "Error: cannot divide by zero — #{e.message}"
rescue TypeError => e
puts "Error: wrong data type — #{e.message}"
ensure
puts "Operation finished." # always runs
end
end
divide(10, 2) # => Result: 5 \n Operation finished.
divide(10, 0) # => Error: cannot divide by zero... \n Operation finished.
divide(10, "a") # => Error: wrong data type... \n Operation finished.
# ANTI-PATTERN: catching every exception without knowing its type
begin
# risky code
rescue Exception => e
puts "There was an error: #{e}" # too broad — this even catches Ctrl+C!
end
# CORRECT: catch specific exceptions
begin
# risky code
rescue ActiveRecord::RecordNotFound => e
# handle record not found
rescue Net::TimeoutError => e
# handle timeout
rescue StandardError => e
# fallback for common errors
end
# Raising your own exceptions
def validate_age(age)
raise ArgumentError, "Age cannot be negative" if age < 0
raise ArgumentError, "Age is unrealistic" if age > 150
age
end
begin
validate_age(-5)
rescue ArgumentError => e
puts "Validation failed: #{e.message}" # => Validation failed: Age cannot be negative
end
# retry — try again after an error
attempts = 0
begin
attempts += 1
puts "Attempt #{attempts}"
raise "Connection failed" if attempts < 3
puts "Success!"
rescue RuntimeError => e
retry if attempts < 3
puts "Giving up after 3 attempts: #{e.message}"
end
Summary #
- Variable prefixes determine scope — no prefix (local),
@(instance),@@(class),$(global). Avoid creating your own global variables.- snake_case for everything — variables, methods, and Ruby files use snake_case, not camelCase.
- Symbols for hash keys — more efficient than Strings because they’re singletons in memory.
- Interpolation, not concatenation —
"Hello, #{name}"is cleaner and faster than"Hello, " + name.- Enumerable iterators beat
for— use.each,.map,.select,.rejectinstead offorloops.- Lambdas are safer than Procs — Lambdas strictly check arguments and their
returndoesn’t leak into the calling method.- Catch specific exceptions — never rescue
Exceptionraw; pick the appropriate exception class.- attr_accessor saves boilerplate — use
attr_reader,attr_writer, orattr_accessorinstead of writing manual getters/setters.- Safe navigation
&.prevents NoMethodError — use it when calling methods on values that might benil.