Skip to content
Published on

How to Be Good at Programming — Principles, Habits, and Depth

Authors

Opening: The Weight of the Word Good

Everyone says they want to be good at programming. But ask what good actually means and the answer goes fuzzy. Some people picture solving algorithm puzzles quickly. Others picture picking up a new framework fast. Still others picture getting something impressive onto the screen in a hurry.

This essay starts by sharpening that blurry definition. I will not offer flashy tricks or any this-is-all-you-need promises. Such promises are usually false, or at best half true. Instead I want to talk about the principles and habits that people who have lasted in this work tend to share, and the concrete ways to build those habits.

Let me say one thing up front. Being good is less a matter of talent than a matter of accumulation. As Peter Norvig argued in "Teach Yourself Programming in Ten Years," depth is not built in a short time. Yet two people can spend the same ten years and accumulate very differently. This essay is about raising the quality of that accumulation.

1. Defining Good: It Works, It Reads, It Lasts

Let us first agree on a definition. I look at well-made code in three layers.

LayerThe questionSymptoms when it fails
It worksDoes it do exactly what was intendedBugs, wrong results, missed edge cases
It readsCan another person understand itSlow reviews, frequent questions, misreadings
It lastsDoes it survive change wellA small edit triggers a cascade of breakage

Beginners see only the first layer. They think that if it works, the job is done. But in real work the lifespan of code is long. Code that is written once and thrown away is rare. The you of six months from now, and a colleague a year from now, will read and edit it again. That is why the second and third layers are decisive.

Brian Kernighan has a famous line. Debugging is twice as hard as writing the code in the first place. So if you write the code as cleverly as you can, then by definition you are not smart enough to debug it. That is why humility belongs in the definition of good.

2. Fundamentals: Understanding Data Structures and Systems

The word fundamentals is often mistaken for interview algorithms. But the real fundamentals are two senses. First, how data is represented and moved in memory. Second, how the code you write actually runs on top of real machines and networks.

2.1 Data Structures Are a Matter of Choice

Memorizing data structures is pointless. What matters is the choice that fits the situation. Look at the following. You should feel how the time complexity of two ways to remove duplicates from a list differs.

# O(n^2) — repeats an in check inside the list
def dedup_slow(items):
    result = []
    for x in items:
        if x not in result:   # linear scan every time
            result.append(x)
    return result

# O(n) — uses a set's hash lookup
def dedup_fast(items):
    seen = set()
    result = []
    for x in items:
        if x not in seen:     # average constant time
            seen.add(x)
            result.append(x)
    return result

The two functions produce the same result. But once the input grows to a hundred thousand items, the first effectively grinds to a halt while the second finishes in the blink of an eye. Fundamentals is the ability to feel this difference the moment you see the code. It is not a memorized complexity table; it is the habit of recalling where and how data is stored.

2.2 Understanding Systems

A web developer should be able to sketch the path a single HTTP request travels. Here is a simplified flow.

browser
  -> DNS lookup (domain -> IP)
  -> TCP connection / TLS handshake
  -> send HTTP request
        -> load balancer
        -> application server
        -> cache lookup (respond here on a hit)
        -> database query
  <- serialize response (JSON, etc.)
  <- render

With this picture in mind, the question of why is this slow no longer leaves you blank. You can narrow the candidates: suspect the cache, the query, or the serialization. Understanding a system is, in the end, a map for narrowing down where a problem lives.

3. Debugging: Observation, Not Guesswork

Many people think of debugging as luck. You poke at the code here and there, and if it happens to fix itself, lucky you. But the debugging of skilled people is closer to science. They form a hypothesis, verify it by observation, and cut the candidates in half each step.

3.1 The Basic Debugging Loop

1. Reproduce  — make the same symptom appear under the same conditions
2. Narrow     — cut the suspect region in half (binary search)
3. Hypothesize — a testable sentence like "this variable will be null"
4. Observe    — confirm the hypothesis with logs, a debugger, or output
5. Fix        — fix the cause and verify the cause, not the symptom, is gone
6. Prevent    — leave a test or guard that stops this kind of bug

The step most often skipped in this loop is step one, reproduce. Trying to fix a bug you cannot reproduce is swinging a blade in the dark. Spending time to build a stable reproduction first is, in the end, the fastest route.

3.2 Binary-Search Debugging

If a value goes wrong somewhere in a long pipeline, plant a single observation point in the middle.

def process(records):
    cleaned = clean(records)
    # observation point: is the value sane here?
    assert all(r.get("id") for r in cleaned), "id is empty"
    enriched = enrich(cleaned)
    return summarize(enriched)

If the assert passes, the problem is after enrich; if it fails, the problem is in clean. One observation halves the candidate space. That is the difference between guessing and science.

4. Abstraction and Simplicity: Less Is Better

Abstraction is a double-edged sword. Good abstraction hides complexity and reduces the cognitive load. Bad abstraction merely relocates the complexity and adds another layer of indirection on top of it.

In his talk "Simple Made Easy," Rich Hickey distinguished simple from easy. Easy is a matter of familiarity; simple is a matter of entanglement. Doing only one thing is simple; what your hands are used to is easy. We often chase the easy and end up building the entangled.

4.1 The Trap of Premature Abstraction

Here is a common mistake. You see similar code in two places and immediately fold it into a shared function.

# a function merged just because it is used in two places
def handle(entity, kind):
    if kind == "user":
        validate_user(entity)
        save_user(entity)
    elif kind == "order":
        validate_order(entity)
        send_invoice(entity)
        save_order(entity)
    # every new kind adds a branch, and the two flows contaminate each other

If you are fooled by surface similarity and merge, the branches grow over time and the function becomes a monster that belongs to no one. When you see two repetitions, it is often better to leave them be. Only when the third appears do you finally see what the true common point is. This is where the maxim a little duplication is better than premature abstraction comes from.

4.2 Measuring Simplicity

Simplicity is not a feeling; it is something you can count. Count the number of concepts a function handles, the number of arguments it takes, the number of states it can be in. The table below is a rough signal.

SignalToward simpleToward complex
Number of arguments3 or fewer6 or more
Boolean argumentsnoneseveral flags that change behavior
Branch depth2 levels or fewernested 4 levels or more
Responsibility of one functiononeseveral (the name contains and)

If the name contains and, that is a signal the function should be split in two. When you see a name like validateAndSave, remember that validation and saving change for different reasons.

5. Readable Code: Code Is Written for People

The compiler accepts any variable name. Variable names exist solely for people. That is why good code reads like prose.

5.1 Names Are Half the Battle

# bad: the intent is not in the name
def f(d, n):
    return [x for x in d if x[1] > n]

# good: the name explains the code
def filter_above_threshold(records, threshold):
    return [r for r in records if r.score > threshold]

The two functions behave the same, but the lower one needs no comment. A good name is the cheapest documentation. And a name need not be perfect from the start. The habit that matters is fixing it the moment the meaning becomes clearer.

5.2 Comments Record the Why

The code itself shows what it does. A comment should record the why.

# bad: a comment that just restates the code
i = i + 1  # increment i by 1

# good: a reason the code alone cannot convey
# the external API counts pages from 1, not from 0
page = page + 1

A comment that explains what becomes a lie the moment the code changes. A comment that explains why survives a long time and rescues the future reader.

6. Testing: Insurance for Your Future Self

Tests are not a tedious obligation but a design tool. Code that is hard to test is usually code with bad design, because its dependencies are tangled or one function is doing too much.

6.1 The Shape of a Good Test

# the function under test
def apply_discount(price, rate):
    if not 0 <= rate <= 1:
        raise ValueError("rate must be between 0 and 1")
    return round(price * (1 - rate), 2)

# the test: looks at boundaries and exceptions together
def test_apply_discount():
    assert apply_discount(100, 0.0) == 100.0     # no discount
    assert apply_discount(100, 0.2) == 80.0      # ordinary case
    assert apply_discount(100, 1.0) == 0.0       # full-discount boundary

    import pytest
    with pytest.raises(ValueError):
        apply_discount(100, 1.5)                  # invalid input

A good test does not look only at the normal case. It looks at boundary values and invalid input together, because bugs usually grow at those edges.

6.2 The Testing Pyramid

        /\
       /  \      E2E tests (few)      — slow but close to reality
      /----\
     /      \    integration (some)   — checks links between components
    /--------\
   /          \  unit tests (many)    — fast and narrowly precise
  /------------\

Put many fast, narrowly pinpointing unit tests at the base, and only a few slow but realistic E2E tests at the top. When this ratio flips, the test suite turns slow and brittle.

7. Incremental Improvement: The Campsite Rule

To borrow from Kent Beck, there is a principle: make the change easy, then make the easy change. Rather than cramming a big refactor into one heroic push, leave the place you touched a little better and walk away. Like the Boy Scout rule, you leave it a little cleaner than you found it.

The core that Martin Fowler's "Refactoring" teaches is small steps. After casting a safety net of tests, you change one thing at a time, reshaping structure without changing behavior. More than a heroic rewrite of grand resolve, the small daily tidying is what keeps a codebase alive.

bad pattern:   6 months of neglect -> a giant rewrite -> a bomb of new bugs
good pattern:  tidy the surroundings a little each commit -> debt never piles up

8. Deliberate Practice: Doing a Lot Does Not Mean Improving

Some people work ten years without improving, while others deepen in three. The difference is deliberate practice. As Anders Ericsson's research shows, plain repetition stalls skill. Only practice at the edge of your ability, with immediate feedback, grows it.

8.1 Beware Comfortable Repetition

Doing again what you already know how to do is rest, not practice. Here are concrete forms of deliberate practice.

  • Pick a language with a different way of thinking, not your familiar one, and build a small project. If you have only done object-oriented, try functional; if only dynamic typing, try static.
  • Once, implement from scratch an abstraction you normally just use. A small key-value store, a mini router, a simple virtual machine.
  • Reread code you wrote a week later and note what got in the way of understanding. That is the pattern to avoid next time.

8.2 Keep the Feedback Loop Short

The effect of practice is proportional to the speed of feedback. Build an environment where tests run the moment you save and the type checker points out the mistake instantly. The faster the feedback, the faster the try-and-fix cycle turns, and fast turning is fast growth.

9. Reading Code: Read Before You Write

A developer spends far more time reading others' code than writing their own. Yet most people never practice reading separately. Those who write well are, almost without exception, those who read well.

9.1 A Strategy for Reading

1. Find the entrance  — start at main, a route handler, the entry point
2. See the big chunks — grasp the directory structure and module boundaries first
3. Follow one flow    — trace one request all the way through
4. Write down questions — record "why did they do it this way?"
5. Verify hypotheses  — make a small edit and confirm with a test

Pick one good open-source project and follow one feature's flow all the way through. It need not be as grand as the Linux kernel. One core function of a library you use every day is enough. Read enough well-written code and the sense of good code soaks into your fingertips.

9.2 What Reading Teaches

Reading others' code, we do not stop at understanding behavior. We absorb which trade-offs the author chose, which cases they prepared for in advance, which names they used to reveal intent. Just as improving your writing requires reading a lot of good writing, the same is true of code.

10. Skills in the AI-Assisted Era: Taste and Verification

We now live in an era where tools write code for us. This change does not make fundamentals meaningless. If anything, it makes two skills more important: taste and verification.

10.1 Taste: An Eye That Recognizes a Good Answer

Generated code looking plausible and being correct are two different things. Look at the following. It is the kind of code a tool often produces.

# plausible but dangerous
def get_user(users, user_id):
    return [u for u in users if u["id"] == user_id][0]

It works. But if user_id is missing it blows up with an IndexError, and if there are duplicates it quietly picks only the first. A person with taste sees this gap at once.

# code that makes the intent explicit
def get_user(users, user_id):
    matches = [u for u in users if u["id"] == user_id]
    if not matches:
        raise KeyError(f"user not found: {user_id}")
    if len(matches) > 1:
        raise ValueError(f"duplicate user id: {user_id}")
    return matches[0]

A generation tool quickly hands you average code. Lifting the average up to the correct is still the work of human taste. And that taste comes from the fundamentals discussed in the earlier chapters.

10.2 Verification: Do Not Trust, Confirm

Generated code is wrong with confidence. That is why verification becomes a core skill. Confirming with tests, running it yourself on small inputs, reasoning through boundary conditions: a tool does not do these for you. The faster the tools get, the more the value of the person who takes responsibility for verifying the result actually rises.

What tools do wellWhat people must own
Generate ordinary code quicklyDefining the problem correctly
Apply familiar patternsJudging trade-offs
Write boilerplateVerifying and owning the result
Remember syntax and APIsKeeping simplicity and taste

11. Anti-Patterns: What Good People Avoid

Growth is partly adding the good, but it is also subtracting the bad. Here are common but expensive anti-patterns.

  • Copy-paste programming: code brought in without understanding will always send a bill someday.
  • Generalizing just in case: abstract ahead of a requirement that has not arrived and, usually, the requirement never comes and only the complexity stays.
  • Heroic debugging: the boast of tracing in your head with no logs and no tests. Debugging without reproduction and observation leans on luck.
  • Silent failure: swallow the exception and return an empty value, and the problem does not vanish but hides further away.
  • Showing off cleverness: clever code crammed into one line satisfies only the author and torments the reader.

What this list has in common is that all of them are convenient in the short term and expensive in the long term. Good people know that lag.

12. A Growth Roadmap: A Map by Stage

Finally, let me sketch a rough but useful map by stage. These stages are not about job titles but about the breadth of one's thinking.

Stage 1: Make it work
  - get comfortable with syntax and tools
  - finish a small program all the way through
  - build the habit of reading error messages

Stage 2: Make it work well
  - feel the difference a data-structure choice makes
  - debug by observation, not by guessing
  - write tests on your own

Stage 3: Make it readable by others
  - reveal intent through names and structure
  - improve incrementally in small units
  - learn by giving and receiving code reviews

Stage 4: Think in systems
  - handle simplicity and abstraction consciously
  - explain trade-offs in words
  - foresee the cost of change in advance

Stage 5: Grow others
  - pass on your judgment criteria through writing and reviews
  - make the team's codebase simpler
  - design the roles of tools and people

What matters in this map is not a fast pass-through. It is staying in each stage long enough to ingrain its sense into your hands. As you climb to a higher stage, the fundamentals of the lower stages do not disappear but become the foundation.

Closing: The Habits of People Who Last

There is no secret to being good at programming. Aim for code that works, reads, and lasts; cultivate a sense for data and systems; debug by observation rather than guesswork; consciously preserve simplicity; write code for people; protect the future with tests; improve a little every day; practice deliberately at the edge of your ability; and diligently read others' code. However fast the tools get, the power of taste and verification that these habits produce remains the human share.

In the end, the good person is not someone with one extraordinary stroke but someone who kept ordinary good habits for a long time. The ten years Norvig spoke of feels long, but when the small daily choices pile up, the path deepens faster than you would think. Start by naming a single function you write today a little more honestly. That is where it begins.

References