One Saturday I gave myself a proper deep work morning. Phone in another room, notifications off, a big coffee, nothing in the calendar until lunch. The plan was to write a database migration for Simple Expense Tracker, one of my small Android apps, so that categories could move out of a free-text column and into their own table.
Three and a half hours later I had read most of the Room documentation on migrations, skimmed two sets of release notes, renamed half the methods in a DAO because the names suddenly bothered me, and tidied the Gradle file. It felt intense. It was quiet, I was absorbed, I didn't touch chat once. I had not written the migration, and I had not written a single test that could tell me if a migration worked.
That morning ticked every box people usually list for deep work, and it produced almost nothing. It took me a while to admit that the problem wasn't the setting.
I had not written the migration, and I had not written a single test that could tell me if a migration worked.
Where the phrase comes from, and the narrower version I use
"Deep work" as a phrase was popularised by Cal Newport's 2016 book of the same name, and most people now use it loosely to mean long, undistracted stretches of hard thinking. That's a reasonable general idea. For programming I've found it too loose to be useful, because you can have the long, undistracted stretch and still spend it browsing.
The version I use is narrower. When I'm coding, depth means holding a specific working set steady long enough to change the system and prove the change. The working set is the stuff that lives in my head and nowhere else unless I write it down: the failing test, the hypothesis, the three files that matter, the uncommitted diff, the command I last ran, the odd thing about the test data. If that set is stable and pointed at a change, I'm in depth. If it isn't, I'm reading, however quiet the room is.
On the Saturday, I never built a working set at all. I had a topic. Topics feel like depth because they're absorbing, but you can't commit a topic.
The migration that showed me the difference
I did the migration the following Tuesday, in about ninety minutes, around a school run and two deliveries. Worse conditions, much better result. The difference was where I started.
I opened the migration test first. Before writing any SQL, I set up the test helper to create a version 3 database, inserted a handful of expense rows with categories as plain strings, and wrote the assertion I wanted to be true after migrating to version 4: every expense points at a row in the new categories table, and nothing is lost. Then I ran it, and it failed for the obvious reason that there was no migration yet.
Now I had a working set. One test, one migration class, the two exported schema files, and one command. I wrote the first version of the SQL: create the table, insert the distinct category names, update expenses to point at the new IDs. The test failed again, and this time it was interesting. I had seeded the test data with what I thought were realistic categories, including "Food" and "food", because I remembered seeing both in my own data on the phone. The new table had a unique index on the name, and the insert fell over.
That was the actual work of the morning: deciding that categories should be merged case-insensitively, writing that into the SQL, and adding a test row with trailing spaces after it occurred to me that people type "Food " too. None of it was hard. All of it depended on keeping the same five or six facts in my head for an hour without dropping them.
Screenshot
Screenshot · 16:9a red migration test in an editor, Food and food colliding on a unique index, showing the working set that made the Tuesday session short, editor capture, ratio 16:9, screenshot
Name the working set before you call a block deep
These days, before I call any block deep, I check I can name these:
The hypothesis or goal in one sentence. "Categories merge case-insensitively and every expense keeps its category."
The failing test and the exact command that runs it.
The files in play. A handful, not the whole repo. If I can't list them, I haven't found the work yet.
Dirty state. What's uncommitted, what's stashed, what I changed in local config to make something run.
Environment quirks. The emulator image, the seed data, the flag, the account, the port.
The artifact that ends the block. A pull request, a passing migration test, a written-up reproduction, a trace with before and after numbers.
If I can't fill in at least the first three, I'm not ready for a deep block, and pretending otherwise is how Saturdays disappear. The fix is usually small: spend ten minutes writing the failing test or the reproduce steps, and the working set appears.
I also keep it to one working set at a time. A second thread "while I'm in here" is a second set competing for the same head space. If something else turns up, it goes on a ticket with a branch name and a next command, and it waits.
Diagram
Diagram · 3:2six parts of a coding working set, from the one-sentence goal to the artifact that ends the block, labeled stack, ratio 3:2, diagram
Size the block to your test loop
Advice about block length is usually generic. For coding I size blocks to the loop: how long it takes to make a change, run the thing that tells me if it worked, and read the result.
When the loop is seconds, forty or fifty minutes can be enough, and two of those with a break between is a good morning. When the loop is long, a fifty-minute block barely fits two attempts. The migration test, in its first form, ran as an instrumented test on an emulator and took close to three minutes each time, including the build. With a loop that slow, I need ninety minutes to get anywhere, and I need a plan for the waits that isn't "start another feature". What to work on while a build runs is my list for those gaps.
The better investment was shortening the loop. Getting that test running on the JVM with Robolectric instead of booting an emulator took about an hour of fiddling and cut each run to well under a minute. That hour bought back more depth than any amount of calendar defence, because every block afterwards had three times as many attempts in it. If your loop is painful, fix the loop before you buy another productivity book.
What dumps the set, including the things I do to myself
The obvious threats are chat, review requests and standups landing in the middle of a block. A "quick question" is cheap for the asker and expensive for me if it arrives while the set is loaded. I've written separately about why interruptions are so expensive during debugging, where the effect is at its worst.
The less obvious threat is me. Half the times I've lost a working set, nobody interrupted me. I went to look up one function signature, found an interesting issue thread, and twelve minutes later came back to a test I no longer understood. Opening the Play Console "for a second" to check something unrelated does the same. These feel like small detours because nobody else was involved, but the set doesn't care who dumped it.
On a team, depth also has to be shared to work at all. If one person disappears into permanent focus while everyone else pings freely, it breeds resentment and the person in focus ends up shipping without the context they needed. Agreed review windows and a clear rule about what counts as urgent work better than any one person's heroics. When a problem needs two people holding the same model, structured pairing can carry a working set between two heads; when remote pairing is worth the cost is about telling when that's true.
End the block like you intend to come back
Halfway through the Tuesday session I had to leave for the school run. Before I stood up, I wrote this on the ticket:
Branch: categories-table
Test: ./gradlew testDebugUnitTest --tests "*Migration3To4Test*"
State: red, unique index on categories.name, "Food" vs "food"
Decision so far: merge case-insensitively, keep first-seen spelling
Next: change INSERT to group by lower(trim(name)), rerun
Dirty: local seed file has extra test rows, do not commit
It took about two minutes. When I sat back down, I was making a change within five, and I didn't reread the diff. Without the note, I know from experience I would have spent twenty minutes reconstructing which spelling had failed and why I'd added the extra rows.
A park card is the cheapest form of depth protection I know. It doesn't stop the interruption. It turns an interruption from a restart into a pause. I treat tomorrow's me as a slightly forgetful colleague, which is the same idea behind restart coding where you left off tomorrow.
Count artifacts, not chair time
The Saturday and the Tuesday look very different if you measure them by output and very similar if you measure by hours of focus. That alone is enough for me to stop tracking focus hours.
A deep block counts when it ends in something reviewable: a pull request with tests, a reproduction written into a ticket, a design decision recorded with its reason, a performance trace with before and after numbers, staging verify notes with accounts and flag state. Refactors get the same bar. "Cleaned up the DAO" with no tests and no measurable outcome is the Saturday all over again.
If my hours go up and my finished artifacts stay flat, I'm leaking working sets somewhere, or doing invisible rework. And a branch that's green but unusable is still only progress; I keep a definition of done that reaches past the pipeline, for reasons I go into in green CI is not finished work.
Days when depth is the wrong target
Some days should not be deep, and trying to force a feature block into them gives you two failures instead of one. Release days are mostly verification and waiting. Triage days are breadth by design. The first week on a new codebase is reading and asking, and that's fine. I name the day type in the morning (depth, review, or coordination) and plan to match. Plan a developer day with review and feature work goes into how I split mixed days.
A drill worth doing once
Here's something I tried that was more useful than I expected. On an ordinary afternoon, mid-block, deliberately open chat for two minutes and read whatever is there. Close it. Then write down what you can no longer remember about the thing you were doing: the assertion text, the file you were about to open, the half-formed idea about the cause.
That list is your personal dump signature. Mine, it turned out, is almost always the exact test command and which variant of the data failed. So those are the first two lines of every park card I write. Yours might be the branch name or the breakpoint location. Whatever it is, park that first next time a real interruption lands.
What I took from that wasted Saturday is that depth isn't something the room gives you. Quiet helps, and protecting a couple of hours, as in protect two uninterrupted hours, helps more. But the part that makes a block deep is having a specific working set pointed at a specific change, and that's something you build in the first ten minutes or don't build at all.
End of this piece
Writer
PrimedEcho
Independent notes on remote work, focus, and freelance practice. About this site.