Ruby the good, bad and ugly

Twenty years of programmer happiness, and the sharp edges nobody warns you about

Contents
<p>The first time Ruby genuinely surprised me, I was reading someone else&rsquo;s code and understood it on the first pass without running it. That sounds trivial until you&rsquo;ve spent a week deciphering a clever one-liner in another language. Ruby reads like a slightly terse version of what you&rsquo;d say out loud — <code>3.times { puts &quot;hi&quot; }</code>, <code>[1, 2, 3].select(&amp;:even?)</code> — and that legibility is not an accident. Yukihiro &ldquo;Matz&rdquo; Matsumoto started designing the language in 1993 and released it publicly in 1995 with an explicit, almost stubborn goal: optimise for the happiness of the programmer, not the convenience of the compiler. Nearly every strength and every wart in Ruby traces back to that one decision.</p> <p>I&rsquo;ve shipped Ruby in production, run it in side projects, and inherited more than one Rails codebase that had been &ldquo;clever&rdquo; for years before I arrived. This is the field report — what&rsquo;s genuinely a joy, what quietly costs you, and what will eventually bite the hand that fed it.</p> <h2 id="why-ruby-feels-the-way-it-does">Why Ruby feels the way it does</h2><div class="ad-unit ad-in-article" aria-label="Advertisement"> <span class="ad-label">Advertisement</span> <ins class="adsbygoogle" style="display:block;text-align:center" data-ad-client="ca-pub-3726833845844946" data-ad-slot="3291553914" data-ad-format="auto" data-full-width-responsive="true"></ins> <script>(adsbygoogle = window.adsbygoogle || []).push({});</script> </div> <p>Ruby is object-oriented in a way most languages only claim to be. In Java, <code>2</code> is a primitive and <code>2.toString()</code> is a special case. In Ruby, <code>2</code> is an object, <code>2.to_s</code> is an ordinary method call, and even <code>nil</code> has methods. <code>nil.to_a</code> returns an empty array. Numbers, strings, classes, and <code>nil</code> itself are all objects with methods you can call and, alarmingly, redefine. Once that clicks, the language stops having exceptions to its own rules, and code you&rsquo;ve never seen before starts behaving the way you&rsquo;d guess.</p> <p>Blocks are the other load-bearing idea. A block is a chunk of code you hand to a method, and they&rsquo;re everywhere: iteration, resource management, DSLs. This is what lets a file get opened, read, and closed safely in one expression:</p> <div class="highlight"><div class="chroma"> <table class="lntable"><tr><td class="lntd"> <pre tabindex="0" class="chroma"><code><span class="lnt">1 </span><span class="lnt">2 </span><span class="lnt">3 </span><span class="lnt">4 </span><span class="lnt">5 </span></code></pre></td> <td class="lntd"> <pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="no">File</span><span class="o">.</span><span class="n">open</span><span class="p">(</span><span class="s2">&#34;config.yml&#34;</span><span class="p">)</span> <span class="k">do</span> <span class="o">|</span><span class="n">f</span><span class="o">|</span> </span></span><span class="line"><span class="cl"> <span class="n">data</span> <span class="o">=</span> <span class="no">YAML</span><span class="o">.</span><span class="n">safe_load</span><span class="p">(</span><span class="n">f</span><span class="o">.</span><span class="n">read</span><span class="p">)</span> </span></span><span class="line"><span class="cl"> <span class="n">data</span><span class="o">.</span><span class="n">fetch</span><span class="p">(</span><span class="s2">&#34;database&#34;</span><span class="p">)</span> </span></span><span class="line"><span class="cl"><span class="k">end</span> </span></span><span class="line"><span class="cl"><span class="c1"># file is closed automatically when the block ends, even on exception</span> </span></span></code></pre></td></tr></table> </div> </div><p>The <code>do...end</code> block receives the open file, runs, and Ruby guarantees the file is closed afterwards. There&rsquo;s no <code>finally</code>, no manual cleanup, no forgotten handle. That pattern — a method that sets something up, yields to your block, then tears it down — is the backbone of idiomatic Ruby, and it&rsquo;s why the code stays flat and readable instead of drowning in nesting.</p> <h2 id="the-good">The good</h2> <p>The good news is mostly about leverage. Ruby lets a small number of people move fast, and it does so honestly rather than by hiding complexity behind magic you can&rsquo;t inspect.</p> <p>The standard library and gem ecosystem are deep and mature. Two decades of &ldquo;someone has already solved this&rdquo; means that for the vast majority of ordinary tasks — HTTP, parsing, background jobs, database access — there&rsquo;s a well-worn gem with sane defaults. Bundler pins your dependency graph reproducibly, which matters more than people admit; keeping that graph fresh is exactly the chore I automate with <a href="/story/renovate-bot-automated-dependency-updates-that-dont-break-everything/">Renovate Bot</a> so a Ruby project doesn&rsquo;t quietly rot between releases.</p> <p>The testing culture is a genuine differentiator. RSpec and Minitest aren&rsquo;t afterthoughts bolted on later — they&rsquo;re woven into how Rubyists work, and the community norm is to test first and refactor with confidence. Because the language is so malleable, mocking and stubbing are trivial, which makes fast unit tests easy to write and easy to overuse.</p> <p>And it&rsquo;s expressive without being cryptic. <code>users.reject(&amp;:banned?).map(&amp;:email)</code> says exactly what it does. Method chaining reads left to right the way you think about the transformation, and the Enumerable module gives every collection the same rich vocabulary. When Ruby is at its best, the code is close to executable prose.</p> <p>Ruby has also kept getting faster where it counts. Ruby 3.3, released on Christmas Day 2023, shipped major YJIT improvements — the Just-In-Time compiler now handles call sites and object shapes it previously bailed on, delivering multiples-faster results on some benchmarks while using less memory than the previous release. The M:N thread scheduler introduced in the same version reduced the cost of creating and managing threads, and Ruby 3.4 landed the following Christmas with further YJIT and parser work. Turning YJIT on is a one-line change with a real payoff on long-running web processes:</p> <div class="highlight"><div class="chroma"> <table class="lntable"><tr><td class="lntd"> <pre tabindex="0" class="chroma"><code><span class="lnt">1 </span><span class="lnt">2 </span><span class="lnt">3 </span><span class="lnt">4 </span></code></pre></td> <td class="lntd"> <pre tabindex="0" class="chroma"><code class="language-console" data-lang="console"><span class="line"><span class="cl"><span class="gp">$</span> <span class="nv">RUBY_YJIT_ENABLE</span><span class="o">=</span><span class="m">1</span> ruby -e <span class="s2">&#34;p RubyVM::YJIT.enabled?&#34;</span> </span></span><span class="line"><span class="cl"><span class="go">true </span></span></span><span class="line"><span class="cl"><span class="err"> </span></span></span><span class="line"><span class="cl"><span class="gp">$</span> ruby --yjit app.rb <span class="c1"># or set the env var in your process manager</span> </span></span></code></pre></td></tr></table> </div> </div><p>This matters because the tired &ldquo;Ruby is slow&rdquo; line is a decade out of date for a lot of real workloads. It was never fully true even then — Ruby spent most web requests waiting on the database — but the modern interpreter has quietly closed a chunk of the gap that the meme was built on.</p> <h2 id="the-bad">The bad</h2><div class="ad-unit ad-in-article" aria-label="Advertisement"> <span class="ad-label">Advertisement</span> <ins class="adsbygoogle" style="display:block;text-align:center" data-ad-client="ca-pub-3726833845844946" data-ad-slot="3291553914" data-ad-format="auto" data-full-width-responsive="true"></ins> <script>(adsbygoogle = window.adsbygoogle || []).push({});</script> </div> <p>The bad is the tax you pay for all that expressiveness, and it&rsquo;s real.</p> <p>Raw single-threaded throughput is still well behind compiled languages. For CPU-bound work — number crunching, tight loops, heavy parsing — Go or Rust will run rings around Ruby, and no amount of YJIT closes that gap entirely. Ruby&rsquo;s answer is that most web workloads are I/O-bound, where the language spends its time waiting on the database or the network anyway, and there it&rsquo;s perfectly competitive. But if you&rsquo;re writing a compute kernel, Ruby is the wrong tool and you should feel no guilt reaching for something else.</p> <p>Concurrency is the historically awkward part. The reference implementation has a Global VM Lock, so threads don&rsquo;t run Ruby code truly in parallel on multiple cores. The traditional workaround is to run multiple processes — Puma and Sidekiq both do this — which works but eats memory, because each process carries its own copy of the runtime. Ractors (added in Ruby 3.0) promise real parallelism with isolated state, but years on they remain rough enough that most production code still leans on the process model. If you&rsquo;re choosing where to put concurrent workloads, this is a genuine constraint, not a footnote.</p> <p>Dynamic typing is a double-edged sword that leans &ldquo;bad&rdquo; as codebases grow. There&rsquo;s no compiler to catch a typo&rsquo;d method name or a <code>nil</code> where you expected a <code>User</code>; you find out at runtime, often in production, often as the dreaded <code>NoMethodError: undefined method 'name' for nil</code>. Sorbet and RBS bring optional static typing, and they help, but they&rsquo;re bolted on rather than native, and adoption is uneven. On a large team, the freedom that felt liberating at ten thousand lines starts feeling like a missing safety net at a hundred thousand.</p> <h2 id="the-ugly">The ugly</h2> <p>The ugly is where Ruby&rsquo;s greatest strength — its malleability — turns on you.</p> <p>Everything is open, including things that shouldn&rsquo;t be. You can reopen any class, including the standard library&rsquo;s, and change it. This is called monkey-patching, and it&rsquo;s genuinely useful right up until two gems both patch <code>String</code> in incompatible ways and you&rsquo;re debugging behaviour that exists in neither library&rsquo;s source. The method that&rsquo;s actually running isn&rsquo;t the one written anywhere you&rsquo;d think to look; it was redefined at load time by code three dependencies deep.</p> <p>Metaprogramming compounds this. <code>method_missing</code>, <code>define_method</code>, and <code>respond_to_missing?</code> let you conjure methods that don&rsquo;t exist until they&rsquo;re called. Rails uses this brilliantly — <code>find_by_email_and_status</code> is not a method anyone wrote — but the cost is that &ldquo;jump to definition&rdquo; in your editor lands nowhere, because there&rsquo;s nothing to jump to. New engineers on a metaprogramming-heavy codebase spend their first weeks confused about where behaviour even comes from.</p> <p>And then the small, sharp corners. <code>&amp;&amp;</code> and <code>and</code> have different precedence, so <code>x = foo and bar</code> does not mean what a newcomer thinks. <code>nil</code> and <code>false</code> are falsey but <code>0</code> and <code>&quot;&quot;</code> are truthy, which trips up anyone arriving from another language. <code>frozen?</code> versus <code>freeze</code>, mutable string literals, the gap between <code>==</code>, <code>eql?</code>, and <code>equal?</code> — none of these are hard once learned, but they&rsquo;re a pile of gotchas that the &ldquo;programmer happiness&rdquo; pitch quietly omits.</p> <div class="highlight"><div class="chroma"> <table class="lntable"><tr><td class="lntd"> <pre tabindex="0" class="chroma"><code><span class="lnt">1 </span><span class="lnt">2 </span><span class="lnt">3 </span><span class="lnt">4 </span><span class="lnt">5 </span></code></pre></td> <td class="lntd"> <pre tabindex="0" class="chroma"><code class="language-ruby" data-lang="ruby"><span class="line"><span class="cl"><span class="c1"># The classic precedence trap</span> </span></span><span class="line"><span class="cl"><span class="n">user</span> <span class="o">=</span> <span class="n">find_user</span> <span class="ow">or</span> <span class="k">raise</span> <span class="s2">&#34;not found&#34;</span> <span class="c1"># assigns find_user, THEN checks — usually what you want</span> </span></span><span class="line"><span class="cl"><span class="n">user</span> <span class="o">=</span> <span class="n">find_user</span> <span class="o">||</span> <span class="k">raise</span><span class="p">(</span><span class="s2">&#34;not found&#34;</span><span class="p">)</span> <span class="c1"># assigns the whole expression — also fine</span> </span></span><span class="line"><span class="cl"><span class="n">result</span> <span class="o">=</span> <span class="n">save</span> <span class="ow">and</span> <span class="n">notify</span> <span class="c1"># &#39;and&#39; binds looser than &#39;=&#39;: result = save, THEN notify</span> </span></span><span class="line"><span class="cl"> <span class="c1"># almost never what you meant</span> </span></span></code></pre></td></tr></table> </div> </div><p>The safe habit is to use <code>&amp;&amp;</code> and <code>||</code> for boolean logic and reserve <code>and</code>/<code>or</code> for control flow, but the language won&rsquo;t stop you mixing them, and the bug that results is invisible until it isn&rsquo;t.</p> <p>There&rsquo;s a cultural dimension to the ugly, too. Because there are usually several idiomatic ways to write the same thing, and because the community holds strong opinions about which is &ldquo;correct&rdquo; Ruby, code review can drift into style arguments that have nothing to do with correctness. Rubocop, the standard linter, exists partly to end those debates by encoding a style guide you can argue with once and then obey — but a badly configured Rubocop can be its own source of friction, nagging about single-versus-double quotes while the actual bug sails through. The tooling is good; the trick is aiming it at real problems rather than aesthetic ones.</p> <h2 id="where-ruby-earns-its-keep">Where Ruby earns its keep</h2> <p>None of the ugly cancels the good. Ruby remains an outstanding choice for web applications, internal tooling, scripting, and anywhere a small team needs to turn ideas into shipped features quickly. Rails, which David Heinemeier Hansson extracted from Basecamp and released in 2004, is still one of the most productive ways to build a database-backed web app, and its convention-over-configuration philosophy rippled out into frameworks in nearly every other language. If you value shipping speed and readable code over squeezing out microseconds, Ruby pays you back.</p> <p>Where it earns its keep least is raw-performance work and very large, long-lived codebases maintained by rotating teams — the exact places where dynamic typing and pervasive metaprogramming turn from assets into liabilities. Every language makes trade-offs; Ruby just makes its trade-offs unusually visible. If you want to see how a language draws that line differently, the same good/bad/ugly treatment for <a href="/story/rust-the-good-bad-and-ugly/">Rust</a> is a useful mirror — it trades Ruby&rsquo;s fluid ergonomics for compile-time guarantees, and reading the two side by side tells you a lot about what you actually value in a language.</p> <h2 id="is-it-worth-learning">Is it worth learning?</h2> <p>Yes, and not only for the jobs. Ruby will make you a better programmer in other languages, because it forces you to internalise objects, closures, and expression-oriented thinking in their cleanest form. Start with plain command-line scripts before you touch Rails, so you learn blocks, modules, and Enumerable on their own terms rather than through Rails&rsquo; considerable magic. Then decide, project by project, whether the trade you&rsquo;re making — expressiveness and speed of development against raw performance and static safety — is the trade you want.</p> <p>For me, on the right kind of project, it almost always has been. Ruby optimised for my happiness, exactly as advertised, and I&rsquo;ve stayed happy long enough to have earned the right to complain about the sharp edges. That&rsquo;s about the highest compliment I can pay a tool.</p>
Advertisement
Advertisement
Smarc
Written by Smarc

Founder and editor of vo.rs. A lifelong tinkerer who self-hosts far more than is sensible, hardens Linux boxes for fun, and prods the latest AI tools to see what they can really do. The how-to guides here are the notes Smarc wishes had existed the first time round.