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 (@variable inside self.method) over @@variable to 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
BlockProcLambda
Store in a variable
Checks argument countNoNoYes (strict)
Return behaviorExits the methodExits the methodExits the lambda
How to createdo..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 runs
def 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, .reject instead of for loops.
  • Lambdas are safer than Procs — Lambdas strictly check arguments and their return doesn’t leak into the calling method.
  • Catch specific exceptions — never rescue Exception raw; pick the appropriate exception class.
  • attr_accessor saves boilerplate — use attr_reader, attr_writer, or attr_accessor instead of writing manual getters/setters.
  • Safe navigation &. prevents NoMethodError — use it when calling methods on values that might be nil.

← Previous: Installation   Next: Comments →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact