Late-night coding never felt particularly dangerous. At 10:30 PM the apartment is quiet, Slack is dead, nobody is asking questions, and a problem that looked annoying at 4 PM suddenly becomes interesting again. One more function, one more test, one small refactoring before bed. Then midnight appears almost without warning. The strange part is that those sessions often feel productive while they are happening. There is no obvious point where the brain starts throwing exceptions. Code still compiles, tests still pass, autocomplete works, and sometimes the solution even looks cleaner than what was written during the day.

The problems usually arrive tomorrow. A branch that felt finished suddenly needs another hour of debugging. An edge case was forgotten. A condition was inverted. A function has two almost identical paths because simplifying them felt harder than adding another if. None of these mistakes is dramatic enough to become a memorable failure, so they disappear into normal development noise. That made me curious about a very simple question: what would happen if coding just stopped at 8 PM? The rule applied specifically to coding. Reading documentation was allowed. Writing notes was allowed. Planning tomorrow was allowed. Looking at an issue was allowed. What was not allowed was opening the IDE to change code, running another debugging session, fixing one last thing or convincing myself that a five-minute patch would actually take five minutes. The experiment lasted 30 days. At first it looked like an experiment about sleep. Pretty quickly it became an experiment about software quality.

The rule was deliberately simple

The cutoff was 20:00 local time. There was no productivity framework wrapped around it, no Pomodoro timer, no elaborate task board and no plan to become a different person in a month. Around 19:50 the current state of the task went into a text file: what worked, what was broken, which command should be run next, and what looked suspicious. Then the repository was left alone until the next day. For comparison I used the previous 30 days on the same project. This mattered much more than it might seem. Comparing one project with another would have made most of the numbers nearly useless because different systems generate very different kinds of mistakes. A month spent changing UI code cannot be compared directly with a month spent working on asynchronous backend processing. In both periods the work was mostly Python backend development, PostgreSQL, API handlers, tests, some asynchronous jobs and ordinary service maintenance.

During the baseline month there were 109.6 hours of active coding. About 27.8 of those hours happened after 8 PM. That number surprised me because mentally those evenings felt occasional. In reality, roughly a quarter of coding time happened late in the day. During the experimental month active coding time fell to 103.2 hours. So removing evening coding did not remove 27 hours of work. Most of it simply moved earlier, and only about six hours disappeared completely. Commit count barely changed: 86 commits in the baseline period and 82 during the experiment. Completed work items also stayed close enough that the month did not feel slower. The interesting part only became visible when I tried to connect bugs back to the commits that had probably introduced them. That turned out to be harder than simply counting commit messages containing fix because Git does not know what a bug is, and a bug-fix commit often touches code originally written by several different commits.

So I wrote a small analyzer. Give it a known bug-fix commit and it inspects the changed lines, looks at the previous version of the file, runs git blame over the relevant ranges and builds a list of likely origin commits. It also records whether those candidate commits were created after 20:00.

#!/usr/bin/env python3

import argparse
import collections
import dataclasses
import datetime as dt
import re
import subprocess
from typing import Iterable


@dataclasses.dataclass(frozen=True)
class Commit:
    sha: str
    timestamp: dt.datetime
    subject: str

    @property
    def after_20(self) -> bool:
        return self.timestamp.hour >= 20


@dataclasses.dataclass
class Candidate:
    commit: Commit
    blamed_lines: int = 0


HUNK_RE = re.compile(
    r"^@@ -(?P<old_start>\d+)(?:,(?P<old_count>\d+))? "
    r"\+(?P<new_start>\d+)(?:,(?P<new_count>\d+))? @@"
)


def git(*args: str) -> str:
    result = subprocess.run(
        ["git", *args],
        text=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.PIPE,
        check=False,
    )

    if result.returncode != 0:
        raise RuntimeError(
            f"git {' '.join(args)} failed:\n{result.stderr}"
        )

    return result.stdout


def load_commit(sha: str) -> Commit:
    raw = git(
        "show",
        "-s",
        "--format=%H%x00%cI%x00%s",
        sha,
    ).strip()

    commit_sha, timestamp, subject = raw.split("\x00", 2)

    return Commit(
        sha=commit_sha,
        timestamp=dt.datetime.fromisoformat(timestamp),
        subject=subject,
    )


def changed_files(fix_sha: str) -> list[str]:
    parent = f"{fix_sha}^"

    output = git(
        "diff",
        "--name-only",
        "--diff-filter=M",
        parent,
        fix_sha,
    )

    return [
        line.strip()
        for line in output.splitlines()
        if line.strip()
    ]


def old_ranges_for_file(
    fix_sha: str,
    filename: str,
) -> Iterable[tuple[int, int]]:
    parent = f"{fix_sha}^"

    diff = git(
        "diff",
        "--unified=0",
        parent,
        fix_sha,
        "--",
        filename,
    )

    for line in diff.splitlines():
        match = HUNK_RE.match(line)

        if not match:
            continue

        start = int(match.group("old_start"))
        count = int(match.group("old_count") or "1")

        if count == 0:
            continue

        yield start, start + count - 1


def blame_range(
    revision: str,
    filename: str,
    start: int,
    end: int,
) -> list[str]:
    output = git(
        "blame",
        "--line-porcelain",
        revision,
        f"-L{start},{end}",
        "--",
        filename,
    )

    commits = []

    for line in output.splitlines():
        if re.match(r"^[0-9a-f]{40} ", line):
            commits.append(line.split()[0])

    return commits


def find_candidates(fix_sha: str) -> list[Candidate]:
    parent = f"{fix_sha}^"
    counts = collections.Counter()

    for filename in changed_files(fix_sha):
        for start, end in old_ranges_for_file(
            fix_sha,
            filename,
        ):
            try:
                blamed = blame_range(
                    parent,
                    filename,
                    start,
                    end,
                )
            except RuntimeError:
                continue

            counts.update(blamed)

    candidates = []

    for sha, line_count in counts.most_common():
        candidates.append(
            Candidate(
                commit=load_commit(sha),
                blamed_lines=line_count,
            )
        )

    return candidates


def print_report(fix_sha: str) -> None:
    fix = load_commit(fix_sha)

    print()
    print("Bug fix")
    print(f"  {fix.sha[:12]}  {fix.timestamp}")
    print(f"  {fix.subject}")
    print()

    candidates = find_candidates(fix_sha)

    if not candidates:
        print("No candidate origin commits found.")
        return

    print("Possible origin commits")
    print()

    for candidate in candidates[:10]:
        commit = candidate.commit

        bucket = (
            "AFTER 20:00"
            if commit.after_20
            else "daytime"
        )

        print(
            f"{commit.sha[:12]}  "
            f"{commit.timestamp:%Y-%m-%d %H:%M}  "
            f"{bucket:11}  "
            f"{candidate.blamed_lines:4} lines  "
            f"{commit.subject}"
        )


def main() -> None:
    parser = argparse.ArgumentParser()

    parser.add_argument(
        "fix_commit",
        help="SHA of the commit that fixes a known bug",
    )

    args = parser.parse_args()

    git("rev-parse", "--is-inside-work-tree")
    print_report(args.fix_commit)


if __name__ == "__main__":
    main()

This is obviously not a perfect bug-introducing-commit detector. Refactoring can destroy the connection between the original mistake and the final fix, and blame can point to the last person who moved a line rather than the person who created the faulty assumption. Because of that, every candidate still had to be checked manually. Tiny typo fixes, dependency updates and defects clearly introduced outside the measured period were excluded.

After that cleanup, the baseline month contained 13 confirmed defects that could reasonably be linked to code written during the same period. Seven of them were primarily traced to commits created after 8 PM. Evening coding represented about 25.4 percent of active coding time, but it accounted for more than half of those defects. That looked suspicious enough to normalize the numbers by hours worked. Daytime code produced roughly 0.73 confirmed defects per ten coding hours. Code written after 8 PM produced about 2.52. That did not prove that evening coding was inherently bad. Difficult tasks could have happened later more often. Some debugging sessions also naturally extended into the evening because the problem had already started earlier. But the difference was large enough that it stopped looking like random noise.

The bugs were not sophisticated

What surprised me most was the type of mistakes. There was no spectacular concurrency failure written at 11:47 PM and no distributed systems disaster that required three engineers and a whiteboard. Most of the evening bugs were boring. One handler accepted an empty identifier because normalization and validation happened in the wrong order. Another bug came from a copied retry branch where a counter was not reset. One SQL query behaved differently because an optional filter had moved from a JOIN condition into WHERE.

These were not problems caused by lack of knowledge. They were problems caused by losing one small condition while simplifying something that looked obvious.

A simplified version of one case looked like this. The original API handler could resolve an account either by account ID or email.

async def resolve_account(request, repository):
    account_id = request.query.get("account_id")
    email = request.query.get("email")

    if account_id:
        account_id = account_id.strip()

        account = await repository.get_by_id(account_id)

        if account is None:
            return {
                "status": 404,
                "error": "account_not_found",
            }

        return {
            "status": 200,
            "account": account,
        }

    if email:
        normalized_email = email.strip().lower()

        account = await repository.get_by_email(
            normalized_email
        )

        if account is None:
            return {
                "status": 404,
                "error": "account_not_found",
            }

        return {
            "status": 200,
            "account": account,
        }

    return {
        "status": 400,
        "error": "missing_identifier",
    }

Late in the evening this looked like an obvious cleanup opportunity. Both branches did almost the same thing, so combining them felt safe.

async def resolve_account(request, repository):
    account_id = request.query.get("account_id")
    email = request.query.get("email")

    identifier = account_id or email

    if identifier is None:
        return {
            "status": 400,
            "error": "missing_identifier",
        }

    identifier = identifier.strip()

    lookup = (
        repository.get_by_id
        if account_id
        else repository.get_by_email
    )

    account = await lookup(identifier)

    if account is None:
        return {
            "status": 404,
            "error": "account_not_found",
        }

    return {
        "status": 200,
        "account": account,
    }

The duplication disappeared, but so did email normalization. There was also a more subtle problem involving truthiness. An empty account ID together with a valid email behaved differently from an account ID containing only whitespace because branch selection happened before normalization. Nothing in the code looked obviously reckless. That is what made the bug interesting.

The safer version kept the lookup strategy and normalization rules explicit.

async def resolve_account(request, repository):
    raw_account_id = request.query.get("account_id")
    raw_email = request.query.get("email")

    if raw_account_id is not None:
        account_id = raw_account_id.strip()

        if not account_id:
            return {
                "status": 400,
                "error": "empty_account_id",
            }

        account = await repository.get_by_id(account_id)

    elif raw_email is not None:
        email = raw_email.strip().lower()

        if not email:
            return {
                "status": 400,
                "error": "empty_email",
            }

        account = await repository.get_by_email(email)

    else:
        return {
            "status": 400,
            "error": "missing_identifier",
        }

    if account is None:
        return {
            "status": 404,
            "error": "account_not_found",
        }

    return {
        "status": 200,
        "account": account,
    }

The important part was not that this bug was difficult. It was almost the opposite. Difficult code forced more attention. Easy-looking code invited shortcuts. Late in the day the dangerous mistakes were often one layer outside the thing currently occupying attention. The main path worked, the function looked cleaner, tests around the obvious case passed, and one small invariant quietly disappeared.

Have you ever opened yesterday's code and immediately seen a mistake that somehow looked completely invisible the night before? After a few repetitions, that stopped feeling like bad luck.

Debugging time changed more than coding time

The next surprise came from debugging. I had already been recording debugging sessions for a while because I wanted to know where working time disappeared. The tracking method was primitive: start time, end time, issue and outcome. A debugging session started when normal implementation stopped and the main activity became explaining unexpected behaviour. It ended when the cause was understood, even if the actual fix was committed later. Before the cutoff, the median debugging session lasted 46 minutes. During the 30-day experiment it fell to 29 minutes. The long tail changed even more. In the baseline period, 90-minute sessions were not unusual. During the experiment they became rare.

At first this looked like a simple consequence of having fewer bugs, but even when I looked only at confirmed defects, debugging sessions were still shorter. My notes showed why. Late debugging often contained loops: check logs, change something, run tests, inspect another file, revert the change, add logging, search somewhere else, return to the first function half an hour later. The next morning the same problem often started with a much cleaner question: which assumption is actually false? Building this analyzer was probably overkill for a rule as primitive as stop coding after eight, but that is also a very programmer-like way to deal with a simple question. Instead of just going to bed, build instrumentation around the decision. At least this script was written before the cutoff.

Sleep improved, but not in the way I expected

During the baseline period, median recorded sleep duration was 6 hours 52 minutes. During the experiment it increased to 7 hours 18 minutes. Median sleep onset moved from roughly 00:37 to 23:54. The data came from a consumer wearable, so treating those numbers as medical measurements would be absurd. I ignored sleep stages completely and only kept start time, end time, total duration and a simple morning score from 1 to 10. What surprised me was that almost nothing changed during the first few nights. Closing the IDE at 20:00 did not magically make the brain stop working. A bug could still follow me into the kitchen. A database query would suddenly seem suspicious while brushing teeth. Sometimes the solution appeared at 11 PM, and the rule meant writing a short note instead of reopening the laptop.

Around the second week, that became normal. The morning score moved from a median of 6 to 7, but the more useful change was subjective: opening yesterday's task no longer felt like reconstructing a crash dump left by another person. There was usually a clean note describing the state of the problem, and there was less code written during the part of the day when attention had already started becoming unreliable. Thirty days are obviously too little to make broad claims about sleep. Workload changed slightly, exercise varied, weather changed, and there was no randomized crossover design. This experiment cannot prove that coding after 8 PM causes shorter sleep. It can only show that during this month, removing late coding happened at the same time as longer recorded sleep, earlier sleep onset, fewer confirmed defects per coding hour and shorter debugging sessions. For a personal engineering experiment, that was enough to be useful.

The most useful change happened before 8 PM

One of the strangest effects appeared around 19:30. Before the experiment, starting a new task at 19:40 felt completely normal because there was no boundary. If the task took two hours, then it took two hours. With a hard stop approaching, opening a new subsystem became obviously stupid. The final half hour slowly turned into cleanup time.

Tests were run. Half-finished experiments were reverted or saved. The next command was written down. Suspicious files were noted. This tiny habit removed a surprisingly large amount of morning context reconstruction.

A typical note looked like this.

Current state:
- /v2/accounts handler works
- retry test still fails on third attempt
- database state looks correct
- suspect RetryPolicy.reset()

Tomorrow:
1. Run:
   pytest tests/api/test_retry.py::test_retry_resets_counter -vv

2. Check:
   services/retry_policy.py:81

3. Do not touch the repository adapter yet.
   Logs show correct values entering it.

This takes maybe two minutes to write, but it can save twenty minutes the next morning. Without the cutoff there was rarely a reason to make such notes because the assumption was always that the task would be finished tonight. Sometimes it was. Sometimes 11:30 PM arrived with three terminal windows open, temporary logging everywhere and a half-understood bug. The artificial boundary forced a small handoff from one version of myself to the next. That turned out to be one of the most useful parts of the experiment.

Fewer hours did not mean proportionally less work

The result I expected least was how little total output changed. Active coding time dropped from 109.6 to 103.2 hours, roughly six hours over the month. Commit count changed from 86 to 82. That is not a productivity metric by itself, but it at least showed that the experimental month did not turn into thirty days of reduced output. What changed more noticeably was the amount of recent work that needed repair. The baseline month contained 13 confirmed defects traced back to code written during that period. The experiment month produced eight. Normalized per ten coding hours, the rate moved from about 1.19 to 0.78. Debugging sessions also became shorter, and several mornings started with implementation instead of repairing code written the night before.

That creates an interesting accounting problem. An extra hour of coding at 10 PM looks like one hour of productivity if time is the only metric. But if that hour creates a defect that costs 45 minutes tomorrow, causes failed tests, forces context switching and makes the same code get reread three times, its real value can become negative. This sounds obvious after the fact. It was not obvious while working. Late-night code feels cheap because tomorrow's repair bill has not arrived yet.

What I would change in a second experiment

The setup had several weaknesses. The biggest one is that 8 PM is arbitrary. Nothing magical happens to source code at 20:00. Another developer might be useless at 7 AM and excellent at midnight. Chronotype, family schedule and normal sleep time matter. A better version would probably use time relative to sleep rather than wall-clock time, for example stopping complex coding three hours before the usual sleep window.

Task difficulty was another problem. Evening sessions may have contained a different mix of work from daytime sessions. Some easy cleanup tasks were intentionally left for later, while difficult debugging sessions sometimes extended into the evening simply because they had already started. A better experiment would tag every session before it begins: implementation, debugging, refactoring, code review, tests or infrastructure. Defect rates could then be compared inside the same type of work. Bug attribution is also imperfect. Git blame is useful, but it is not a time machine. Code may pass through several refactorings before a defect is discovered. For a longer experiment, I would probably record change sets directly and connect defects to them while the context is still fresh.

And finally, 30 days is short. One strange release week can distort everything. Still, it was enough to break one assumption that had survived for years: that evening coding was just normal coding performed later. For me, it was not.

So am I never coding after 8 PM again?

Probably not. Production incidents do not care about personal experiments. If something important breaks at 21:30, the IDE is going to open. There are also evenings when working on a side project is genuinely fun, and turning every hobby session into a productivity optimization exercise sounds like a good way to stop enjoying programming. The rule that survived is less rigid. After 8 PM, starting new complex work now feels suspicious. If the task is already understood and the remaining work is mechanical, maybe it is fine. If the next step requires designing an API, debugging unfamiliar behaviour, changing concurrency logic or writing a database migration, tomorrow is probably cheaper.

That word cheaper is what changed most. Before the experiment, an extra evening hour was just one extra hour. Now that hour also includes tomorrow's debugging probability, the cost of rebuilding context and the chance that code written while feeling completely normal is not actually normal-quality code. The sleep improvement was useful, but it was not the result that made the rule stick. What made the difference was looking at Git history and seeing that a relatively small part of the working day had produced a surprisingly large amount of repair work.

If you already have a few months of Git history, bug reports and some kind of sleep log, this is an easy experiment to reproduce. And if you do try it, there is one number I would be genuinely interested in comparing: what percentage of your bugs were originally written during the last few hours of your working day?