The Magic of +”” and -“” in Ruby

Demystifying Unary String Operators for Performance and Safety

Ruby is renowned for its developer happiness and elegant syntax. It’s a language where common tasks often read like natural English. However, beneath this friendly surface lie powerful, slightly esoteric features designed for fine-grained control and performance optimization. One such feature – often puzzling to newcomers and occasionally overlooked by seasoned developers – is the use of unary operators on strings: specifically, +”” and -“” .

If you’ve ever dug into the source code of popular Ruby gems like Rails or sidekiq, you might have stumbled across a line like this and paused:

buffer = +""

What exactly is happening here? Why not just write buffer = “” ? Let’s dive into the mechanics, the advantages, and why this tiny symbol makes a significant difference.

The Problem: The Frozen String Literal Pragma

To understand +”” , we first have to understand a major shift in Ruby’s approach to memory management.

Historically, every time you declared a string literal in Ruby, a new object was created in memory. If you had a loop that printed “hello” 1,000 times, Ruby instantiated 1,000 distinct string objects, creating work for the garbage collector.
To combat this, Ruby 2.3 introduced the frozen string literal pragma:

frozen_string_literal: true

When placed at the top of a file, this magic comment instructs Ruby to freeze all string literals in that file. A frozen string cannot be modified. They become constants in memory, drastically reducing object allocations. This is considered a best practice in modern Ruby development.

However, this introduces a new problem. What if you want to build a string dynamically using append operations ( << )?

frozen_string_literal: true
buffer = ""
buffer << "Hello" # => FrozenError (can't modify frozen String)

The Solution: The Unary Plus ( +”” )

Enter the unary + operator. Introduced in Ruby 2.3 alongside the frozen string pragma, + explicitly unfreezes a string literal, returning a mutable copy.

frozen_string_literal: true
buffer = +""
buffer << "Hello "
buffer << "World"
puts buffer # => "Hello World"

In short: +”” says to Ruby, “I know frozen strings are enabled here, but I specifically need this particular string to be mutable because I plan to change it.”

Why is this better than String.new ?

You could achieve the same result using String.new .

buffer = String.new

Functionally, +”” and String.new achieve the same goal. However, +”” is generally preferred in the Ruby community for a few reasons:
* Brevity: It’s significantly shorter and reads more like a literal assignment.
* Idiomatic: It has become the recognized standard idiom in modern Ruby libraries.
* Performance (Micro-optimization): Historically, evaluating the literal +”” was marginally faster than the method dispatch required for String.new , although modern Ruby versions have largely leveled this playing field.

The Counterpart: The Unary Minus ( -“” )

If + unfreezes a string, what does – do? The unary minus does the opposite: it
guarantees a string is frozen and deduplicated.

frozen_string_literal: false (or omitted)
str1 = -"immutable"
str2 = -"immutable"
puts str1.object_id == str2.object_id # => true
str1 << " change" # => FrozenError (can't modify frozen String)

When you use – , Ruby checks an internal “frozestring” table. If a frozen string with the identical content already exists, it returns a reference to that existing object rather than creating a new one. This is equivalent to calling “immutable”.freeze , but it is syntactically cleaner when used inline.

Why Do Developers Miss This?

If these operators are so useful, why aren’t they universally understood?
* It’s visually subtle: The difference between “” and +”” is a single character. It’s easy for the eyes to glide over it during code review or while casually reading a library’s source code.
* It relies on file-level pragmas: If you aren’t in the habit of using #
frozen_string_literal: true
in your projects, you rarely encounter the
FrozenError that necessitates +”” . Many smaller scripts or older legacy
applications run without the pragma, meaning a regular “” works fine as a
mutable buffer.
* It feels “un-Ruby-like”: Ruby is usually explicit and readable (e.g., [1,
2].empty? ). Using arithmetic operators like + and – on strings to control memory allocation feels a bit like C-style pointer manipulation, which breaks the mental model some developers have of the language.

Best Practices & Takeaways

To write modern, performant, and safe Ruby code, adopt these habits:
* Always freeze by default: Add # frozen_string_literal: true to the top of all new Ruby files. It’s an easy win for memory efficiency.
* Use +”” for buffers: When you need to incrementally build a string using << ,
initialize it with +”” .
* Avoid += in loops: Building strings with += creates a new object on every iteration, regardless of pragmas. Always prefer appending to a mutable buffer with << .

BAD (Creates 1001 string objects)
frozen_string_literal: true
result = +""
1000.times { result += "a" }
GOOD (Creates 1 mutable string object and modifies it in place)
frozen_string_literal: true
result = +""
1000.times { result << "a" }

The unary operators + and – on strings are small, esoteric features that pack a
significant punch. Understanding them not only helps you write better code but also enables you to read and understand the source code of the Ruby ecosystem’s most robust libraries.

Files

Download PDF:

Happy Rubying!

Learn SQL: Day 7 – Query Optimization Workshop

Welcome to Day 7.

Everything we’ve learned so far leads to this lesson.

Until now, you’ve learned:

  • How to write SQL
  • How JOINs work
  • How GROUP BY works
  • How indexes work
  • How PostgreSQL chooses execution plans

Today, we’ll combine everything to solve real-world performance problems.


Today’s Goals

By the end of today, you’ll be able to answer questions like:

  • Why is this query slow?
  • Should I add an index?
  • Should I rewrite the query?
  • Is this a database problem or an application problem?
  • How would I debug this in production?

These are exactly the kinds of discussions that happen in senior Rails interviews.


A Senior Engineer’s Workflow

Suppose your manager says:

“The Users page takes 8 seconds to load.”

A junior developer might immediately say:

“Let’s add an index.”

A senior developer thinks:

1. Is the query actually slow?
2. Which query is slow?
3. How much data is involved?
4. What is PostgreSQL doing?
5. Can I rewrite the query?
6. Do I need an index?
7. Is the application causing the problem?

Notice:

Adding an index is Step 6, not Step 1.

Our Practice Schema

Let’s build something closer to a real Rails application.

DROP TABLE IF EXISTS order_items;
DROP TABLE IF EXISTS orders;
DROP TABLE IF EXISTS products;
DROP TABLE IF EXISTS users;

Users

CREATE TABLE users (
    id BIGSERIAL PRIMARY KEY,
    name TEXT,
    email TEXT,
    city TEXT
);

Products

CREATE TABLE products (
    id BIGSERIAL PRIMARY KEY,
    name TEXT,
    price NUMERIC(10,2),
    category TEXT
);

Orders

CREATE TABLE orders (
    id BIGSERIAL PRIMARY KEY,
    user_id BIGINT NOT NULL REFERENCES users(id),
    status TEXT,
    created_at TIMESTAMP DEFAULT NOW()
);

Order Items

CREATE TABLE order_items (
    id BIGSERIAL PRIMARY KEY,
    order_id BIGINT NOT NULL REFERENCES orders(id),
    product_id BIGINT NOT NULL REFERENCES products(id),
    quantity INTEGER,
    price NUMERIC(10,2)
);


The Six-Step Performance Checklist

Every slow query investigation should start with this checklist.

Step 1 – Measure

Never optimise blindly.

Run:

EXPLAIN ANALYZE
SELECT ...

Step 2 – Understand the Business Question

Example:

Show the last 20 completed orders.

Don’t optimise before understanding what the query should do.

Step 3 – Read the Plan

Look for:

  • Seq Scan
  • Nested Loop
  • Hash Join
  • Sort
  • Aggregate
  • Bitmap Heap Scan

Step 4 – Find the Bottleneck

Ask:

  • Which node took the most time?
  • Which node processed the most rows?

Step 5 – Decide the Fix

Possible fixes:

  • Better index
  • Better SQL
  • Better schema
  • Better ActiveRecord
  • Better pagination

Step 6 – Measure Again

Never assume the optimisation worked.

Always compare before and after.


Scenario 1 – Missing Index

Query:

SELECT *
FROM users
WHERE email='john@example.com';

Execution plan:

Seq Scan
rows=100000
actual rows=1

Question:

What’s wrong?

Diagnosis

No index on email.

Fix

CREATE INDEX idx_users_email
ON users(email);

Run again:

EXPLAIN ANALYZE
SELECT *
FROM users
WHERE email='john@example.com';

Expect:

Index Scan

Scenario 2 – Wrong Index

Suppose the application runs:

SELECT *
FROM users
WHERE city='Chicago'
AND age=30;

Indexes:

(city)
(age)

Question:

Better solution?

Answer

Composite index:

CREATE INDEX idx_city_age
ON users(city, age);

Because the application almost always filters by both.


Scenario 3 – Sorting

Query:

SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 20;

Plan:

Seq Scan
↓
Sort
↓
Limit

Question:

Can we avoid sorting?

Solution

CREATE INDEX idx_orders_created_at_desc
ON orders(created_at DESC);

Now PostgreSQL can read the index in order.

Often:

Index Scan
↓
Limit

No Sort node.


Scenario 4 – N+1 Queries

Rails code:

orders = Order.limit(100)

orders.each do |order|
  puts order.user.name
end

SQL executed:

SELECT * FROM orders LIMIT 100;

Then:

SELECT * FROM users WHERE id=1;

SELECT * FROM users WHERE id=2;

…

100 additional queries.

Total

101 queries

Fix

Order.includes(:user)

Now:

SELECT * FROM orders;
SELECT *
FROM users
WHERE id IN (...);

Two queries.

Interview Question

Which is faster?

includes

or

joins

Answer:

They solve different problems.


Scenario 5 – OFFSET Pagination

Query:

SELECT *
FROM orders
ORDER BY created_at DESC
LIMIT 20
OFFSET 100000;

Looks harmless.

But PostgreSQL must skip:

100000 rows

before returning:

20 rows

Large OFFSET values become increasingly expensive.

Better Solution

Keyset Pagination.

Instead of:

OFFSET 100000

Use:

WHERE created_at < '2026-07-01'
ORDER BY created_at DESC
LIMIT 20;

This lets PostgreSQL continue from the last seen row instead of counting through earlier rows.

Rails Example

Instead of:

Order.order(created_at: :desc)
     .offset(100000)
     .limit(20)

Use:

Order
  .where("created_at < ?", last_created_at)
  .order(created_at: :desc)
  .limit(20)

This is called keyset pagination or cursor pagination.


Scenario 6 – SELECT *

Query:

SELECT *
FROM users;

Returns:

id
name
email
city
address
bio
avatar
...

Suppose the page only displays:

  • name
  • city

Why fetch everything?

Better:

SELECT
name,
city
FROM users;

Rails:

User.select(:name, :city)

Scenario 7 – COUNT(*)

Suppose:

SELECT COUNT(*)
FROM orders;

On:

300 million rows

Question:

Can this be slow?

Yes.

Because PostgreSQL must count visible rows.

Unlike some databases, PostgreSQL generally doesn’t maintain an exact row count that’s instantly available for arbitrary COUNT(*).


Scenario 8 – DISTINCT

Query:

SELECT DISTINCT users.*
FROM users
JOIN orders
ON users.id=orders.user_id;

Question:

Why is DISTINCT needed?

Because JOIN duplicates users.

Could EXISTS express the requirement more directly?

SELECT *
FROM users u
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.user_id=u.id
);

Sometimes that’s clearer.


Scenario 9 – Functions in WHERE

Query:

SELECT *
FROM users
WHERE LOWER(email)='john@example.com';

Normal email index?

Not useful.

Solution:

CREATE INDEX idx_lower_email
ON users(LOWER(email));

Expression index.


Scenario 10 – Too Many Indexes

Suppose:

users
↓
12 indexes

Problem?

Every INSERT must update:

Table
+
12 indexes

Indexes speed reads but slow writes.

Always consider the workload.


Optimization Decision Tree

When a query is slow, ask:

Is PostgreSQL scanning too many rows?
│
▼
Yes
│
▼
Would an index help?
│
▼
Yes
│
▼
Do I already have one?
│
▼
No
│
▼
Create the correct index.

But also ask:

Am I returning unnecessary data?
Am I sorting unnecessarily?
Am I joining unnecessarily?
Am I executing the query too many times?

Real Rails Optimization Example

Suppose this page loads slowly:

@orders = Order
            .where(status: "completed")
            .order(created_at: :desc)
            .limit(20)

Questions:

  1. Is there an index on status?
  2. Is there an index on created_at?
  3. Would a composite index help?

Potential solution:

CREATE INDEX idx_orders_status_created_at
ON orders(status, created_at DESC);

Why?

Because the query filters by status and orders by created_at.


Senior Interview Exercise

Suppose you see:

SELECT *
FROM orders
WHERE user_id = 100
ORDER BY created_at DESC
LIMIT 20;

Which index would you create?

Many people answer:

(user_id)
(created_at)

A stronger answer is:

CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);

Because it supports both the filter and the ordering in one index.


Common Performance Mistakes

Mistake 1

Adding indexes without measuring.

Mistake 2

Using SELECT * everywhere.

Mistake 3

Ignoring N+1 queries.

Mistake 4

Using huge OFFSET values.

Mistake 5

Creating duplicate indexes.

Mistake 6

Ignoring EXPLAIN ANALYZE.

Senior-Level Mental Model

Every query has a “cost.”

The cost comes from:

Rows read
+
Rows sorted
+
Rows joined
+
Rows transferred
+
Application round trips

The goal of optimisation is to reduce one or more of these.


Practical Exercises

Exercise 1

Create:

CREATE INDEX idx_orders_user_created
ON orders(user_id, created_at DESC);

Run:

EXPLAIN ANALYZE
SELECT *
FROM orders
WHERE user_id=1
ORDER BY created_at DESC
LIMIT 20;

Observe whether PostgreSQL can avoid an explicit Sort.

Exercise 2

Compare:

SELECT *
FROM users;

with:

SELECT name, city
FROM users;

Think about the amount of data returned.

Exercise 3

Write three versions of:

“Find users with completed orders.”

Using:

  1. JOIN
  2. EXISTS
  3. IN

Then compare their execution plans.

Exercise 4

Find a query that performs a Seq Scan.

Add an appropriate index.

Run EXPLAIN ANALYZE again.

What changed?


Interview Case Study

Imagine you’re in a senior Rails interview.

The interviewer says:

“A customer reports that the Orders page takes 6 seconds to load.”

A strong answer isn’t:

“I’ll add an index.”

A stronger answer is:

  1. Reproduce the issue.
  2. Identify the SQL generated by ActiveRecord.
  3. Run EXPLAIN ANALYZE.
  4. Inspect scan types, joins, and sort operations.
  5. Check existing indexes.
  6. Decide whether the fix belongs in the SQL, indexes, ActiveRecord code, or schema.
  7. Measure again after the change.

That systematic approach demonstrates senior-level thinking.


Homework

Build a small benchmark using your practice schema.

  1. Populate:
    • 100,000 users
    • 500,000 orders
  2. Measure these queries before and after adding indexes:
    • Find a user by email.
    • Find recent orders for a user.
    • Find completed orders.
    • Find users with no orders.
  3. For each query, record:
    • Execution plan
    • Execution time
    • Scan type
    • Rows estimated
    • Rows returned
  4. Explain why PostgreSQL chose each plan.

What’s Next?

At this point, you’re already covering topics that many experienced Rails developers never study in depth.

For Day 8, I recommend Window Functions:

  • ROW_NUMBER()
  • RANK()
  • DENSE_RANK()
  • LAG()
  • LEAD()
  • Running totals
  • Moving averages
  • Top N per group

Window functions are common in reporting, analytics, and senior backend interviews because they solve problems that are difficult or inefficient with plain GROUP BY. Understanding them will significantly broaden your SQL toolkit.

Happy Learning! 🚀