Estimating Software Projects Without Lying to Yourself

Abstract: Most estimation failures aren’t math problems, they’re honesty problems — we know a task is uncertain and we write down a single confident number anyway. This post covers a three-point estimation technique with working code, and a way to track your own estimation accuracy over time so you stop making the same optimistic mistake project after project.

The lie we tell in a single number

Someone asks how long a feature will take. You think about it for thirty seconds and say “three days.” That number is not an estimate, it’s a guess dressed up as one, because it doesn’t account for anything going wrong — the third-party API turning out to be undocumented, the migration touching more tables than expected, the client changing their mind about the sort order halfway through.

I did this for years and blamed the client, the requirements, the tooling, anything but the estimate itself. The actual problem was simpler: a single number can’t represent uncertainty, so whatever number I picked was quietly assuming the best case and calling it typical.

Three-point estimation, minus the ceremony

PERT estimation asks for three numbers instead of one: optimistic, most likely, and pessimistic. The weighted formula is old and well documented, but the part that matters isn’t the formula, it’s that writing down a pessimistic number forces you to actually think about what could go wrong, which a single “three days” never does.

def pert_estimate(optimistic: float, most_likely: float, pessimistic: float) -> dict:
    """
    Weighted PERT estimate. Inputs are in whatever unit you're
    estimating in (hours, days), all three required.
    Returns the expected value and a standard deviation you can
    use to size a confidence range.
    """
    if not (optimistic <= most_likely <= pessimistic):
        raise ValueError("expected: optimistic <= most_likely <= pessimistic")

    expected = (optimistic + 4 * most_likely + pessimistic) / 6
    std_dev = (pessimistic - optimistic) / 6

    return {
        "expected": round(expected, 1),
        "std_dev": round(std_dev, 1),
        "range_68pct": (round(expected - std_dev, 1), round(expected + std_dev, 1)),
        "range_95pct": (round(expected - 2 * std_dev, 1), round(expected + 2 * std_dev, 1)),
    }


# Example: a migration task
result = pert_estimate(optimistic=2, most_likely=4, pessimistic=10)
print(result)
# {'expected': 4.7, 'std_dev': 1.3, 'range_68pct': (3.4, 6.0), 'range_95pct': (2.1, 7.3)}

Notice what the pessimistic number does to the expected value. A “most likely” of 4 days becomes an expected 4.7 once the pessimistic tail is factored in, and the honest answer to “how long will this take” is really a range: 3.4 to 6 days at moderate confidence, wider than that if you want to be more sure. I’ve started giving clients the range instead of the single number, and it’s changed almost none of the conversations I expected it to ruin — most clients handle “4 to 6 days, most likely 5” better than a single number that turns out wrong.

Estimating at the task level, not the feature level

The other lie is estimating a feature as one unit instead of breaking it into tasks first. “Add user export” sounds like a two-day estimate until you break it into pieces: querying the data, formatting it, handling large result sets, writing the download endpoint, and testing against real data volumes. Each of those has its own uncertainty, and some of them you’re confident about while others you’re not.

def estimate_feature(tasks: list[dict]) -> dict:
    """
    Sum PERT estimates across subtasks. Each task is a dict with
    optimistic/most_likely/pessimistic keys. Variance sums linearly
    for independent tasks, so we sum std_dev^2 and take the root
    at the end rather than summing std_dev directly.
    """
    total_expected = 0.0
    total_variance = 0.0

    for task in tasks:
        est = pert_estimate(task["optimistic"], task["most_likely"], task["pessimistic"])
        total_expected += est["expected"]
        total_variance += est["std_dev"] ** 2

    total_std_dev = total_variance ** 0.5

    return {
        "expected": round(total_expected, 1),
        "std_dev": round(total_std_dev, 1),
        "range_68pct": (round(total_expected - total_std_dev, 1), round(total_expected + total_std_dev, 1)),
    }


tasks = [
    {"optimistic": 0.5, "most_likely": 1, "pessimistic": 2},   # query the data
    {"optimistic": 0.5, "most_likely": 1, "pessimistic": 1.5}, # format as CSV
    {"optimistic": 1, "most_likely": 2, "pessimistic": 5},     # large result sets
    {"optimistic": 0.5, "most_likely": 1, "pessimistic": 2},   # endpoint + auth
    {"optimistic": 1, "most_likely": 1.5, "pessimistic": 3},   # testing
]

print(estimate_feature(tasks))
# {'expected': 6.7, 'std_dev': 1.1, 'range_68pct': (5.6, 7.8)}

Summing variances rather than standard deviations is the one place people get this wrong when they do it by hand, since it’s tempting to just add the std_dev values directly. That overstates the combined uncertainty, because it’s unlikely every task hits its pessimistic case at once. Summing the variances and taking the square root at the end gives you a tighter, more realistic range across a set of tasks.

Tracking whether your estimates are actually any good

None of this matters if you don’t check yourself against the outcome. I keep a small table of estimated versus actual time per task, and I look at it before starting a new project of similar shape, not to punish myself but to find my own bias.

CREATE TABLE dbo.EstimateLog (
    TaskId          INT IDENTITY(1,1) PRIMARY KEY,
    ProjectName     NVARCHAR(200)   NOT NULL,
    TaskDescription NVARCHAR(500)   NOT NULL,
    EstimatedHours  DECIMAL(6,1)    NOT NULL,
    ActualHours     DECIMAL(6,1)    NULL,
    TaskType        NVARCHAR(50)    NULL,  -- e.g. 'integration', 'ui', 'migration'
    CompletedOn     DATE            NULL
);

-- Bias check: are you systematically underestimating a particular
-- category of work? Run this every few months.
SELECT
    TaskType,
    COUNT(*)                           AS TaskCount,
    AVG(ActualHours - EstimatedHours)  AS AvgHoursOver,
    AVG(ActualHours / NULLIF(EstimatedHours, 0)) AS AvgRatio
FROM dbo.EstimateLog
WHERE ActualHours IS NOT NULL
GROUP BY TaskType
ORDER BY AvgRatio DESC;

The AvgRatio column is the one worth watching. If integration tasks come out at 1.6 on average and UI tasks come out at 1.05, that’s not noise, that’s a pattern — I’m consistently underestimating integration work by 60 percent and I should either pad those estimates going in or find out why. On one client project this log showed I was underestimating anything involving a third-party API by almost double, every time, regardless of how carefully I thought I’d scoped it. Once I saw the number I started doubling those specific estimates by default, and the follow-on estimates got much closer to actual.

What this doesn’t fix

Three-point estimation doesn’t help with unknown unknowns — work you haven’t identified at all, as opposed to work you’ve identified and are uncertain about. No estimation technique catches the requirement nobody mentioned. It also doesn’t help if the underlying “most likely” number is itself dishonest, padded down because you know the client wants to hear a small number. The technique only improves the estimate you already intend to give honestly; it can’t fix an incentive to lie.

It’s also more overhead than a single number, and for genuinely small tasks — an hour here, half a day there — it’s not worth the ceremony. I reserve this for anything estimated at more than a day, where the cost of the extra five minutes is trivial next to the cost of being wrong. For risk tracking on top of the estimate itself, see my earlier post on <a href=”https://gerixsoft.com/risk-management-for-small-dev-teams”>risk management for small dev teams</a>, since a pessimistic estimate and an open risk are often two views of the same underlying uncertainty.

Conclusion

An estimate that doesn’t account for what could go wrong isn’t an estimate, it’s an optimistic guess with a due date attached. Three numbers instead of one, summed correctly across subtasks, and checked against your own track record afterward, gets you most of the benefit of formal estimation methods without the process overhead a small team can’t afford to carry.

FAQ

Isn’t a range harder for clients to accept than a single number? In my experience, less than expected. Most clients have been burned by confident single numbers before and respond well to a range with a clear “most likely” value called out, especially once you explain why.

How many subtasks is too granular to bother estimating separately? If a subtask is under half an hour, I don’t bother splitting it out; the overhead of estimating it separately exceeds the benefit. Anything you’d genuinely be unsure how long it takes is worth its own three numbers.

What do I do if I don’t have historical data yet to calibrate against? Start logging now even without a comparison point. The first few months of an estimate log are mostly for establishing your baseline; the bias-checking value shows up after you have a few dozen completed tasks to compare against.

Does this work for fixed-bid projects? It works better for fixed-bid, not worse — the pessimistic tail is exactly the number you should be pricing risk against, since on a fixed bid you eat the pessimistic case yourself rather than passing it to the client.

Should the whole team estimate together, or one person alone? I prefer a quick sanity check from at least one other person, particularly on the pessimistic number, since it’s the number people are most tempted to shrink to make the estimate look better.

Leave a Reply

Your email address will not be published. Required fields are marked *