Educational Blog

How to Read Unfamiliar Code One Function at a Time

Learn a repeatable method for understanding unfamiliar code by tracing one function, its inputs, outputs, dependencies, side effects, and callers.

When unfamiliar code feels overwhelming, the problem is usually not that every line is difficult—it is that you do not yet know which lines matter. A function-by-function reading strategy gives you a smaller question to answer at each step and gradually builds a reliable mental model.

Start with a specific question

Do not begin by trying to understand an entire repository. Start with a question that gives your investigation a boundary:

  • Where does a form submission get saved?
  • Why does this API request return an empty list?
  • Which function decides whether a user can access a page?
  • What transforms this database record before it reaches the UI?
  • Where is this error created or handled?

A focused question helps you choose a starting point and prevents unrelated code from consuming your attention. If you have no bug or feature to investigate, choose one visible user action, such as opening a page, clicking a button, or running a command.

Write the question down. As you read, separate confirmed facts from assumptions. For example:

  • Confirmed: createInvoice() receives a customer ID and a list of items.
  • Assumption: it probably validates that the customer exists.
  • Unknown: whether taxes are calculated inside this function or elsewhere.

This small distinction prevents guesses from quietly becoming part of your mental model.

Find the entry point

The entry point is the place where the behavior begins. It may be a route handler, event listener, command-line function, scheduled job, controller, component callback, or test.

Use the project’s structure and naming conventions to narrow the search. Common places include:

  • routes, controllers, or handlers for web requests
  • commands or cli for terminal programs
  • components and event callbacks for frontend interactions
  • jobs, workers, or tasks for background work
  • services or use-cases for application operations
  • tests for examples of intended behavior

Search for the visible name of the feature, endpoint, error message, button label, or command. If you find several matches, compare their context rather than opening every file immediately.

Once you locate a likely entry point, read its whole function before following anything else. Note its parameters, return value, local variables, conditions, and calls to other functions. You are not trying to understand every implementation detail yet. You are identifying the shape of the operation.

A useful first sketch looks like this:

request
  -> load account
  -> validate input
  -> calculate result
  -> save changes
  -> return response

The sketch can be incomplete. Its purpose is to create a map that you can revise.

Read the function contract first

Before reading line by line, ask what the function promises. Even when no formal documentation exists, most functions have an implied contract.

Record these six details:

  1. Inputs: What arguments arrive? What types, shapes, and values are expected?
  2. Output: Does the function return a value, a promise, a response object, a boolean, or nothing?
  3. State changes: Does it write to a database, update an object, modify a file, or change global state?
  4. Dependencies: Which imported functions, services, clients, or constants does it use?
  5. Failure behavior: Does it throw, return an error, log a warning, or silently fall back?
  6. Timing: Is it synchronous, asynchronous, retried, cached, or deferred?

You can summarize a function in one sentence:

buildProfile(userId) loads a user, combines account and preference data, removes private fields, and returns a display-ready object.

If you cannot write a sentence like this, do not immediately dive into every helper. First identify the function’s boundaries and the names of the operations it performs.

Trace one call at a time

The central technique is controlled tracing. Follow one function call into its implementation, understand only enough to explain its role, then return to the caller.

Suppose you see:

const items = await getAvailableItems(userId);
const total = calculateTotal(items, discount);
return formatOrder(items, total);

Read getAvailableItems() first. Answer:

  • Where does it get the items?
  • Does it filter inactive records?
  • Can it return an empty array?
  • Does it throw when the user is missing?

Then return to the original function and summarize that call in plain language: “Loads the user’s currently available items.” Next, inspect calculateTotal(), then formatOrder().

Avoid opening five levels of nested helpers at once. Deep exploration makes it easy to lose the original question and confuse implementation detail with important behavior.

A practical rule is to follow a call only when one of these is true:

  • Its result affects the behavior you are investigating.
  • Its name is misleading or ambiguous.
  • It performs I/O or changes state.
  • It contains a condition that could explain the bug.
  • You need to know its failure behavior.

If a helper is a straightforward formatter or standard library wrapper, record its role and move on.

Distinguish data flow from control flow

Two kinds of information matter when reading code. Data flow describes how values move. Control flow describes which paths execute.

For data flow, track a value from its origin to its destination:

HTTP body -> parsed input -> validated input -> domain object -> database row

Ask what changes at each step. Is a field renamed, converted, filtered, defaulted, or encrypted? Pay special attention to transformations that can remove information or alter units, dates, casing, and identifiers.

For control flow, list the branches:

if no account       -> return 404
if invalid input    -> return 400
if not authorized   -> return 403
otherwise           -> perform update

Do not assume a branch is impossible because it is uncommon. An unfamiliar function often appears simple until an early return, exception handler, feature flag, or retry path changes the result.

A compact table can keep the two views together:

Code areaQuestion to answerUseful note
ParametersWhat enters the function?Include optional and default values
Main callsWhat work is delegated?Record each call’s purpose
ConditionsWhich paths are possible?Note early returns and guards
MutationsWhat state changes?Identify files, records, and objects
ReturnWhat leaves the function?Include errors and empty results

Inspect boundaries and side effects

The most important code is often at a boundary: a database call, network request, file operation, message publish, cache access, or process environment lookup. Boundaries are where assumptions about the outside world enter the program.

For every boundary, ask:

  • What exact data is sent or requested?
  • Is the operation authenticated?
  • Is the result trusted, validated, or normalized?
  • What happens on timeout or partial failure?
  • Is the operation retried, and could retries duplicate work?
  • Is there a transaction or rollback strategy?
  • Does the caller know whether the operation succeeded?

Side effects deserve special attention because they may not appear in the return value. A function can return true while also writing a record, emitting an event, updating a cache, or logging sensitive information.

Mark side effects directly in your notes. For example:

saveOrder(order) -> writes orders table
publishOrderCreated(order) -> sends message to queue
formatOrder(order) -> no side effects

This distinction helps you decide which helpers are safe to call repeatedly and which ones require caution.

Use names, types, and tests as evidence

Names are clues, not proof. A function called validateUser() may also normalize input, query a database, or throw exceptions. Confirm what it does by reading its implementation and callers.

Types are often more reliable. In a typed language, inspect interfaces, schemas, generics, and nullable fields. In a dynamically typed language, look for validation code, constructors, sample objects, and tests that reveal the expected shape.

Tests are especially useful because they show behavior in concrete examples. Read them to discover:

  • required inputs
  • expected outputs
  • important edge cases
  • error types and messages
  • whether a function is intended to mutate data

However, tests can be incomplete, outdated, or focused only on happy paths. Treat them as evidence of expectations, not a complete specification.

Configuration and environment variables also affect behavior. A function may appear to use one service while choosing another based on a flag. Check configuration only when it influences the path you are tracing; avoid reading every deployment file at the beginning.

Keep a call graph that stays small

A call graph is a map of which functions call which other functions. You do not need to document the entire application. Build only the portion relevant to your question.

Use a simple notation:

handleCheckout
  -> parseCart
  -> loadPrices
      -> priceClient.fetch
  -> createPayment
      -> paymentGateway.charge
  -> sendReceipt

Add short notes beside nodes that matter:

createPayment
  -> may throw PaymentDeclined
  -> writes payment record before gateway call

If the graph grows too large, stop and summarize the branch you just explored. A useful summary might be: “This branch only converts database rows into view models; it does not affect authorization or persistence.” Then return to the main path.

For recursive functions, callbacks, event handlers, and dependency injection, the call relationship may not be visible in one file. Follow registration points and interfaces, but keep the same rule: identify the next behavior that affects your original question.

Handle unfamiliar syntax strategically

You do not need to master an entire language before reading one function. When syntax slows you down, isolate the construct and translate it into plain language.

Common examples include:

  • map: create one output value for each input value
  • filter: keep values that satisfy a condition
  • reduce: combine many values into one result
  • await: pause this asynchronous function until a promise settles
  • destructuring: extract named values from an object or positions from an array
  • closures: a function retains access to variables from its surrounding scope
  • middleware: code that runs around or before a handler
  • decorators or annotations: metadata or behavior attached to a declaration

If an expression is dense, rewrite it temporarily as several named steps in your notes. For example, turn a chained transformation into “select active records, sort by date, take the first five.” The goal is comprehension, not stylistic judgment.

Then investigate only the syntax you still cannot explain. This keeps language learning connected to a real behavior.

Troubleshoot when the path is unclear

When tracing stalls, use targeted alternatives instead of guessing.

If you cannot find where a function is called:

  • Search for the exact function name and exported name.
  • Check indirect references such as configuration, dependency injection, or event registration.
  • Look for route tables, command registries, or callback arrays.
  • Search for the interface or type rather than the implementation name.

If a value appears from nowhere:

  • Trace the parameter backward through the caller chain.
  • Check defaults, environment variables, request middleware, and object spreading.
  • Look for mutation before the function is called.
  • Add the value’s type and expected shape to your notes.

If control flow seems impossible:

  • Check for exceptions and rejected promises.
  • Inspect feature flags and configuration.
  • Look for early returns inside helpers.
  • Verify whether a value can be null, undefined, empty, or stale.

If two files seem to implement the same behavior:

  • Compare their callers and exported names.
  • Check whether one is a test, legacy path, platform-specific version, or generated file.
  • Find which one is imported by the entry point.

When tools are available, a debugger, call hierarchy view, type checker, and test runner can confirm your reading. If tools are unavailable, static reasoning still works: write down each assumption and identify the smallest piece of code that would confirm or disprove it.

Know when to stop exploring

Function-by-function reading has limits. A function may depend on framework behavior, generated code, external services, undocumented database rules, or concurrency that is not visible locally. You may also reach a point where understanding more detail no longer helps answer the original question.

Stop a branch when you can explain:

  • what it receives
  • what it returns or changes
  • which important conditions alter its behavior
  • what errors or side effects matter
  • how it connects to the surrounding function

Record unresolved questions separately. For example:

Unknown: whether the queue guarantees ordering.
Known: this code publishes one event after saving the record.
Next evidence: inspect consumer assumptions or queue configuration.

This prevents uncertainty from being mistaken for failure. You can now move to the next function, reproduce the behavior, or make a focused change with a clear understanding of its likely impact.

Written by

shiftedup.com Editorial Team

Editorial team

Independent editorial coverage of code & developer life.