Ralph Jonas Mungcal

Ralph Jonas Mungcal

@ralphdev.bsky.social

Software engineer building in public | sharing what works, what breaks, and what I learn shipping https://ralphjonas.com/

39 followers11 following498 posts

We ran a one-off script to fix one customer record. The UPDATE had no WHERE, so it rewrote that field on every row in the table. A missing filter does not mean change nothing extra. It means change all of them. Write the WHERE before the SET, and dry-run it as a SELECT first.

We grouped orders by customer and selected a column we never aggregated. The engine picked an arbitrary row for it, so dev looked right and prod shipped wrong totals. A column outside the GROUP BY is whatever the engine happens to return. Aggregate it, or put it in the GROUP BY.

We fed the month into the date constructor as one through twelve. It counts months from zero, so every date came out one month ahead and December rolled into January. To that constructor, month one is February, not January. Check what it counts from before you pass it a number.

We passed the search box straight into a LIKE query. Someone typed a single percent sign, and the filter matched every row in the table. A percent sign in a LIKE is a wildcard, not a letter. Escape the wildcards before they reach the query.

We set the session cookie without HttpOnly, so any script could read it. One injected line could lift the token and ride the user's session. A cookie the browser hides from script is not one it hands over. Set HttpOnly on the session cookie, and Secure and SameSite with it.

We summed the order total in a 32-bit int, and one large order pushed it past the max. The number wrapped around to negative, and we billed the customer less than zero. An int has a ceiling, money does not. Hold money in a wide integer of minor units, and guard every multiply.

We called the payments API from the browser with the secret key sitting in the JavaScript bundle. Anyone could open devtools, lift the key, and spend against our account. Anything you ship to the browser is public. Keep the secret on the server and proxy the call through it.

We capped a profile name at a fixed byte length and cut it in the middle of a character. The last letter turned into a broken box, and every name with an accent or kana saved mangled. A byte is not a character. Measure and trim the text by characters, never by raw bytes.

We built the validation regex inside the handler, so it recompiled on every call. The pattern never changed, yet the compile ran on the hot path and dragged latency up. Compiling a pattern is startup work, not per request work. Compile it once and reuse the compiled object.

Our login returned no account found for unknown emails and wrong password for real ones. A scraper read the gap and walked our whole user list in an afternoon. A different error message is a different answer. Give both cases the same response and the same timing.

A partner sent dates like 01/02/2026 and our parser read them month first. They meant day first, so February records jumped to January and the report was wrong. A date with no stated format is a guess. Agree on one explicit format and parse to it, never the server locale.

We picked the daily featured post with order by random, limit one. It sorted the whole table on every request to hand back a single row. A random row is not a full sort. Pick by a random id or a precomputed sample and leave the table alone.

Nobody owned renewing the TLS certificate, so it reached its expiry date. At midnight it lapsed, every client refused the connection, and the service went dark on a date we knew a year out. An expiry date is a deadline, not a surprise. Monitor it and renew it before it lands.

We counted the whole table on every page load just to show the total. Instant at ten thousand rows, seconds at ten million, and it ran on every request. An exact total is a full table scan you pay for on every page. Cache the count or drop the precise number.

We set next year's billing date by bumping the year number by one. On February 29 that date did not exist, the renewal job threw, and every customer due that day went unbilled. A calendar is not a counter. Add a year with real date math and clamp to a day that exists.

We shipped a debug endpoint and called it internal. It had no auth because nothing was supposed to reach it, and a scanner found it from the open internet in a day. Internal is a wish, not a control. Put auth on it or do not ship it.

For every row we scanned the whole list to find its match. It ran in a blink on a hundred rows and crawled for minutes on a hundred thousand. A lookup inside a loop is a table scan you wrote by hand. Build the index once, then read from it.

We trusted the order keys came back from a hash map. A runtime upgrade shuffled that order, the first match flipped, and a request that used to pass started failing in prod. A map holds pairs, not an order. If the order matters, sort it before you read it.

We validated the input with a regex that had no anchors. It matched a safe fragment anywhere in the string, so a crafted value slipped a bad payload past the check and into the handler. Matching a part is not matching the whole. Anchor the pattern to the start and the end.

We added an event listener every time the view mounted and never removed it. The user moved back and forth all day, the listeners stacked up, and memory climbed until the tab froze. A listener you add is a listener you have to remove. Clean it up when the owner goes away.

We passed a timeout between two services with no unit attached. One side meant seconds, the other read milliseconds, and the call gave up almost instantly under load. A number with no unit is a guess. Put the unit in the name and the type.

We let users set a greeting, then rendered it through the template engine. One account set its greeting to a template expression, the server ran it, and read back an internal value. User input is data, not a template to run. Render it as text, never as part of the template.

We shared one date formatter across every request to save the allocation. Two requests formatted at once stepped on its state, and a batch of timestamps came out scrambled. One shared instance is not thread safe by default. Give each request its own instance.

We filled in a default whenever the quantity looked missing, with a plain truthiness check. A customer ordered zero of one add on, the check read that zero as missing, and it quietly restored the default. Absent is not the same as zero. Check for null, not for falsy.

We extracted user uploaded archives to disk. One entry carried leading parent paths, so it wrote outside the target folder and clobbered a file we needed. A path from an archive is not a safe path. Resolve each entry and reject any that escapes the target folder.

We measured how long each job ran by subtracting two readings of the wall clock. The clock stepped back during a time sync and one job logged a negative duration that poisoned the metrics. Wall time is not elapsed time. Measure durations with a monotonic clock.

We parsed uploaded XML with external entities turned on. One file pointed an entity at a path on our own server and the parser read that file straight back into the response. A document is not a set of instructions to run. Turn off DTDs and external entities on the parser.

We cached only rows that existed. Every request for an id that was not there skipped the cache and hit the database, so a flood of bad ids drove full traffic onto it. A missing row is still a result worth caching. Cache the empty answer too, with a short expiry.

Our rate limiter counted requests in fixed one minute windows. A client fired a full burst at the end of one window and another at the start of the next, landing twice the limit in two seconds. A count per window is not a rate. Use a sliding window or a token bucket.

We saved the config by writing straight over the old file. The process was killed mid write, and the next start read a truncated file that no longer parsed. A half written file is not a saved file. Write to a temp file, fsync it, then rename it over the original.

Our parser recursed once per level of nesting with no depth limit. A deeply nested payload drove it thousands of calls deep, blew the stack, and took the process down. Untrusted input is not a trusted shape. Cap the nesting depth, or parse with an explicit stack, not recursion.

We checked the token with loose equality. The language coerced the types, so an empty value and a zero compared as equal, and a blank token passed the check. A value that looks equal is not the same value. Compare with strict equality, and reject missing input first.

We made random UUIDs the primary key on our hottest table. Each insert hit a random spot in the clustered index, splitting pages nonstop, and insert throughput collapsed. A random key is not a free key. Use a sequential id, or a time ordered UUID like v7.

Our kill switch read its flag from an env var. The value was the string false, and a non-empty string is truthy, so the switch stayed on right through the incident. The string false is not the boolean false. Config stays text until you parse it. Parse it to a real boolean.

We encrypted our stored passwords and called them safe. One leaked key and every password was plaintext again. Encryption is reversible by design. That is the point. A key you hold is a key an attacker can take. Hash passwords with a slow one-way function. Never encrypt them.

We added a unique index to stop duplicate rows. They kept landing anyway. The column was nullable, and null never equals null, so the index skipped every null row. A unique index on a nullable column is not a uniqueness guarantee. Make the column not null, or filter the index.

We deserialized a request payload straight into live objects. A crafted blob built a type we never expected and ran its own code on our box. Decoding attacker input is not the same as trusting it. Parse to plain data. Validate the fields. Never hydrate arbitrary types.

We ran blocking database calls on the async worker pool. One slow query held every worker, so requests that never touched the DB stacked up behind it and timed out. A thread stuck waiting is not a thread doing work. Keep blocking work off the async pool. Give it a bounded one.

We summed order totals across a join to their line items. Every order has many lines, so the join repeated each order row, and the revenue number came back two and three times too high. A joined row is not a distinct order. Sum the lines in a subquery first, then join the total.

We serialized the whole user record into the API response. The password hash and the internal flags went out with it, sitting in the JSON every client could read. What you store is not what you should return. Pick the fields you expose. Send those, nothing else.

We built a new HTTP client for every outbound request. None of the connections got reused, the sockets piled up waiting to close, and the box ran out of ports under load. A fresh client per call is not free. Build one client, share it, let it pool the connections.

We added a NOT NULL column with no default. Old code was still live during the rolling deploy, so its inserts left the column out and every write failed until the rollout caught up. A schema change is not in step with your deploy. Add it nullable, backfill, then enforce.

We decoded the JWT and trusted the claims inside it. We never verified the signature, so a token with its algorithm set to none sailed through, and anyone could become any user. A decoded token is not a verified one. Verify the signature with a pinned algorithm. Reject the rest.

We put the user id into a metric label. Every user became its own time series, the count ran into the millions, and our metrics box ran out of memory during an outage. A label is not a place for unique values. Keep labels low cardinality. The id goes in a log, not a label.

We returned 64-bit record ids to the browser as plain JSON numbers. Anything past 2^53 rounded on parse, so two orders came back under the same id and we opened the wrong one. A JSON number is not a 64-bit integer. Send big ids as strings, and read them back as strings.

We bound the whole request body straight onto the user model on update. One user added a field the form never showed, set their role to admin, and the API saved it. They promoted themselves. A request body is not a safe object. Allowlist the fields you accept. Ignore the rest.

We paged the export with limit and offset while new rows were still coming in. Each insert shifted everything down a slot, so some records exported twice and others never showed at all. An offset is not a stable cursor. Page by a key that does not move. Seek, do not count.

We left debug logging on in prod to chase one bug. The log file grew until the disk hit 100%, and the database stopped taking writes for everyone. A log line is not free. Rotate logs, cap the level in prod, and alert on disk before it fills.

We let users give a URL for their avatar and fetched it server-side. One aimed it at the internal metadata endpoint. Our server dialed it from inside the network and handed back secrets. A URL from a user is not a safe destination. Validate the host. Block private ranges first.

We checked if the email was taken, saw it was free, then inserted the user. Two signups ran that check at once, both saw it free, and made two accounts for one address. An application check is not a constraint. Put a unique index on the column. Let the database say no.