Errors vs Bugs: The Difference That Actually Matters
Software fails. That is not the interesting part. The interesting part is how it fails, because not all failures are the same. There is a fundamental distinction between two kinds of failure — errors and bugs — and most developers use these words interchangeably despite them meaning completely different things. Getting clear on this distinction will change how you write code, how you handle failures, and how much time you spend staring at production logs at two in the morning.
What Is an Error?
An error is a failure that the system knows about. Something went wrong, the system detected it, and it told you. You get a message, a location, a stack trace, context. The system is shouting: "This broke, here is where, here is why."
When you receive an error, you are not investigating. You are responding. The hard work of figuring out what happened has already been done — by the code that threw the error in the first place.
In PHP, the typical mechanism is an exception:
<?php
declare(strict_types=1);
final class PaymentService
{
public function charge(Order $order): PaymentResult
{
if ($order->getTotal()->isNegative()) {
throw new \InvalidArgumentException(
sprintf(
'Order %s has a negative total: %s',
$order->getId(),
$order->getTotal()->format()
)
);
}
try {
return $this->gateway->charge($order->getTotal());
} catch (GatewayTimeoutException $e) {
throw new PaymentFailedException(
'Payment gateway timed out for order ' . $order->getId(),
previous: $e
);
}
}
}
When this fails, you know what happened. The exception type tells you the category of failure. The message gives you context. The stack trace pinpoints the location. The chained previous exception preserves the original cause. You can fix this in minutes.
TypeScript has the same pattern:
class InvoiceService {
generate(order: Order): Invoice {
if (order.items.length === 0) {
throw new ValidationError(
'Cannot generate invoice: order has no items',
{ orderId: order.id }
);
}
const total = order.items.reduce(
(sum, item) => sum + item.price * item.quantity,
0
);
if (!Number.isFinite(total) || total <= 0) {
throw new CalculationError(
'Invoice total is invalid',
{ orderId: order.id, calculatedTotal: total }
);
}
return { orderId: order.id, total, generatedAt: new Date() };
}
}
Both examples share the same principle. The developer anticipated what could go wrong, wrote code to detect it, and made sure the failure would be loud and specific. That is an error. It is a known, anticipated failure mode, and dealing with it is straightforward.
Think About It Like a Car
You are driving and the oil warning light comes on. You know immediately what the problem is: oil pressure is low. You know why the light triggered. You know what to do about it. Pull over, check the oil, top it up or call for service. The warning system did its job. The problem was detected, reported, and you can act on it.
That is an error.
Now imagine something different. Over the past few weeks, your car has been losing power gradually. Fuel consumption is creeping up. Sometimes the engine hesitates when you accelerate. There is no warning light. No diagnostic code. Just a vague sense that something is not right. You take it to a mechanic and they start investigating. Could be the fuel injectors. Could be the catalytic converter. Could be an air leak in the intake manifold. Could be a dozen other things. That process of diagnosis, elimination, and detective work is expensive and slow.
That is a bug.
The oil light gave you everything you needed. The mystery power loss gave you nothing except a symptom. The distinction between these two experiences is the distinction between errors and bugs in software.
What Is a Bug?
A bug is something that has gone wrong, but the system has no idea. No exception is thrown. No warning is logged. The code runs to completion and produces a result. The problem is that the result is wrong, and nobody knows until the consequences surface as symptoms somewhere else entirely, possibly weeks later.
You never find a bug directly. You find symptoms. Then you have to work backwards to figure out the cause. That is bug fixing, and it is one of the most expensive activities in software development.
Here is a PHP example. See if you can spot it:
<?php
declare(strict_types=1);
final class DiscountCalculator
{
public function applyDiscount(Money $price, int $discountPercent): Money
{
$multiplier = (100 - $discountPercent) / 100;
return $price->multiply($multiplier);
}
}
This code runs without complaint. No exceptions. Static analysis probably will not flag it. But if $discountPercent is 15, then (100 - 15) / 100 performs integer division and evaluates to 0, not 0.85. Every customer gets a 100% discount. The system processes orders at zero cost with absolutely no indication that anything is wrong.
What is the symptom? Maybe a finance report three weeks later showing revenue has collapsed. Maybe a customer support ticket from someone puzzled about being charged nothing. Maybe an inventory anomaly. The symptom appears far from the cause, separated by time and layers of code, and the investigation to connect the two is where the real cost lives.
Here is a TypeScript example with the same dynamic:
interface UserPreferences {
theme: 'light' | 'dark';
notifications: boolean;
language: string;
}
function mergePreferences(
defaults: UserPreferences,
overrides: Partial<UserPreferences>
): UserPreferences {
return { ...defaults, ...overrides };
}
// In an API handler:
app.put('/preferences', (req, res) => {
const updated = mergePreferences(defaults, req.body);
savePreferences(user.id, updated);
res.json(updated);
});
No runtime error. TypeScript is satisfied at compile time. But req.body can contain anything — { theme: "rainbow", isAdmin: true } — and the spread operator will blindly merge it all. There is no validation at the system boundary. Data gets silently corrupted, and you will not discover it until some other part of the system tries to use those preferences and encounters values it does not expect.
Why the Distinction Matters: Cost
The difference between errors and bugs is not academic. It is financial. They have fundamentally different cost profiles.
When an error fires in production:
- An alert triggers (seconds)
- A developer reads the message and stack trace (minutes)
- The developer understands the cause (minutes)
- A fix is written and deployed (minutes to hours)
When a bug surfaces as a symptom in production:
- Someone notices something seems "off" (days to weeks)
- The symptom gets reported and triaged (hours)
- A developer tries to reproduce it (hours)
- The developer traces the symptom back through layers of code to find the cause (hours to days)
- A fix is written that addresses the root cause, not just the symptom (hours)
- The blast radius is assessed — what else did this affect? (hours)
- Data cleanup and remediation, if needed (hours to days)
The error took minutes. The bug took days or weeks. And the bug had a much longer window to cause damage because nobody knew it was there. The error was a fire alarm. The bug was a slow gas leak.
Converting Bugs into Errors
Once you see the cost difference clearly, the strategic conclusion is obvious: convert potential bugs into errors wherever you can. Every check, assertion, type constraint, and validation you add is taking a silent failure and making it loud. You are moving problems from the "bug" column into the "error" column, where they cost orders of magnitude less to deal with.
This is not defensive programming for its own sake. It is a deliberate economic decision.
Strict Typing
PHP's declare(strict_types=1) converts silent type coercion into TypeErrors:
<?php
// Without strict_types — a bug
function calculateTax(float $amount, float $rate): float
{
return $amount * $rate;
}
calculateTax("not a number", 0.2);
// PHP coerces the string to 0.0, returns 0.0
// No warning. No exception. Just wrong.
// With strict_types — an error
declare(strict_types=1);
calculateTax("not a number", 0.2);
// TypeError: Argument #1 must be of type float, string given
// Exact location. Exact cause. Fixed in minutes.
TypeScript's strict: true achieves the same thing at compile time. The point is language-agnostic: stricter type systems turn silent bugs into loud errors.
Value Objects and Domain Assertions
The integer division bug from the discount calculator can be made impossible with a value object:
<?php
declare(strict_types=1);
final class Percentage
{
public function __construct(
public readonly float $value
) {
if ($value < 0.0 || $value > 100.0) {
throw new \DomainException(
sprintf('Percentage must be between 0 and 100, got %f', $value)
);
}
}
public function asMultiplier(): float
{
return (100.0 - $this->value) / 100.0;
}
}
final class DiscountCalculator
{
public function applyDiscount(Money $price, Percentage $discount): Money
{
return $price->multiply($discount->asMultiplier());
}
}
The original bug is now structurally impossible. The Percentage value object guarantees float arithmetic and validates the range at construction time. Pass in new Percentage(150) and you get a DomainException — an error, not a bug.
Boundary Validation
The TypeScript preferences bug disappears once you validate at the system boundary:
import { z } from 'zod';
const PreferencesSchema = z.object({
theme: z.enum(['light', 'dark']).optional(),
notifications: z.boolean().optional(),
language: z.string().min(2).max(10).optional(),
}).strict();
app.put('/preferences', (req, res) => {
const parsed = PreferencesSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({
message: 'Invalid preferences',
errors: parsed.error.issues,
});
}
const updated = mergePreferences(defaults, parsed.data);
savePreferences(user.id, updated);
res.json(updated);
});
The .strict() modifier rejects unknown fields. Someone sending { isAdmin: true } now gets a 400 response with a clear message, not silent data corruption. The bug has been converted into an error.
Static Analysis: Errors Before Runtime
The ultimate form of bug-to-error conversion happens before the code runs at all. Static analysis tools move bugs from "symptom in production" all the way to "red underline in your editor".
<?php
declare(strict_types=1);
// PHPStan level 8+ catches this:
function findUser(array $users, string $email): User
{
foreach ($users as $user) {
if ($user->getEmail() === $email) {
return $user;
}
}
// PHPStan: Method findUser() should return User
// but return statement is missing.
// Without PHPStan, this silently returns null — a bug.
// With PHPStan, this is caught at development time — an error.
}
// @typescript-eslint/switch-exhaustiveness-check
type Status = 'pending' | 'active' | 'cancelled';
function getStatusLabel(status: Status): string {
switch (status) {
case 'pending':
return 'Pending';
case 'active':
return 'Active';
// ESLint: Switch is not exhaustive.
// Missing case: 'cancelled'
}
}
Every static analysis rule you enable is another class of bug that gets promoted to an error before it can ever reach a user. PHPStan, Psalm, ESLint, TypeScript strict mode — they all serve the same purpose. They turn silent wrongness into loud complaints.
Fail Fast: The Only Sane Response
Once you understand the cost difference between errors and bugs, there is really only one rational strategy: fail fast. The moment something is wrong, stop. Throw an exception. Return an error. Refuse to continue. Do not try to soldier on in a semi-broken state hoping things will work out.
A system that fails fast converts every problem into an error. A system that tries to be "resilient" by swallowing failures and pressing on converts every problem into a bug. The first system is cheap to operate. The second is a minefield.
Consider what happens when you catch an exception and silently continue:
<?php
declare(strict_types=1);
// This is a bug factory
function loadConfig(string $path): array
{
try {
return json_decode(
file_get_contents($path),
true,
512,
JSON_THROW_ON_ERROR
);
} catch (\Throwable) {
return []; // "graceful" fallback
}
}
This code looks defensive and safe. It is neither. When the config file is missing or malformed, the system continues running with an empty config. No error is raised. No alert fires. The application just quietly behaves differently from what you expect, and you have no idea why. You have taken a perfectly good error and turned it back into a bug.
The fail-fast version is better in every way:
<?php
declare(strict_types=1);
function loadConfig(string $path): array
{
if (!is_file($path)) {
throw new \RuntimeException(
sprintf('Config file not found: %s', $path)
);
}
$contents = file_get_contents($path);
if ($contents === false) {
throw new \RuntimeException(
sprintf('Failed to read config file: %s', $path)
);
}
return json_decode($contents, true, 512, JSON_THROW_ON_ERROR);
}
If anything is wrong, you know about it immediately. There is no window of time where the system runs in a broken state accumulating invisible damage. The failure is loud, specific, and caught within seconds of deployment.
The same principle applies everywhere. A database query returns unexpected data? Do not patch it up and continue. An API response is missing a required field? Do not substitute a default. A configuration value is outside its valid range? Do not clamp it silently. Every one of these "helpful" fallbacks is a bug waiting to happen. Every one of them trades a cheap, obvious error now for an expensive, mysterious investigation later.
Fail fast is not about being fragile. It is about being honest. A system that crashes when something is wrong is telling you the truth. A system that limps along pretending everything is fine is lying to you, and you will pay for that lie eventually.
The Mindset Shift
Once you internalise the error vs bug distinction, it changes how you think about every line of code you write. Error handling stops looking like defensive overhead and starts looking like an investment that pays for itself many times over.
The goal is not to write code that never fails. That is impossible and not even desirable. The goal is to write code that fails loudly, clearly, and early. A system full of well-crafted errors is a system that is cheap to operate. A system full of silent bugs is a system that is slowly, invisibly rotting — and every rotten piece is a future investigation waiting to consume someone's week.
The question to ask yourself when writing any piece of logic is not "what if this fails?" It is: "if this fails silently, how long before anyone notices, and how much damage will it do in the meantime?" If the answer makes you uncomfortable, add a check. Turn that potential bug into an error. Your future self will thank you for it.