Python · SQL · Web Dev · Java · AI/ML tracks launching soon — your one platform for all of IT
Advanced+150 XP

Debugging Techniques and Tools

Reading a traceback the right way, the real limits of print debugging, pdb, VS Code's debugger, and a full worked debugging session.

50 min August 2026

This module opens Phase 6 — Production & Career Readiness, the final phase of this curriculum. Everything up to this point has been about writing Python that works. Phase 6 is about the skills that turn "it works on my machine" into a professional habit: debugging systematically instead of guessing, logging instead of printing, packaging instead of copy-pasting scripts between machines, profiling before optimizing, and eventually walking into a technical interview and explaining all of it clearly. Debugging comes first because it is the single most used skill in this entire phase — you will lean on it while learning every module that follows.

// Part 01 — Reading a Traceback

Bottom to Top — Not Top to Bottom

Almost every beginner reads a Python traceback the way they read everything else in English — top to bottom. That is exactly backwards, and it is the single biggest reason new engineers stare at a wall of text and freeze. A traceback is a call stack, printed in the order Python walked into each function to reach the line that finally failed. The bottom of the traceback is where the actual failure happened. Everything above it is just the trail of function calls that led there.

A realistic traceback
Traceback (most recent call last):
  File "app.py", line 22, in <module>
    total = calculate_order_total(cart)
  File "app.py", line 15, in calculate_order_total
    line_total = compute_line_item(item)
  File "app.py", line 9, in compute_line_item
    return item["price"] * item["quantity"]
KeyError: 'price'

Read it from the bottom up. KeyError: 'price' is the actual exception — the program tried to access a dictionary key called "price" that did not exist. The line directly above it, line 9, in compute_line_item, is exactly where that lookup happened. Everything further up — compute_line_item was called from calculate_order_total, which was called from the top-level module — is context, useful for understanding why that function ran with bad data, but not where the bug actually lives. A beginner who reads top-down often starts investigating calculate_order_total first, when the real fix belongs three lines lower.

The exception type tells you the category of bug before you read anything else

Before reading a single line of your own code, read the exception's name. It is a direct classification of what went wrong, and recognizing the category instantly narrows your search. A KeyError means a dict lookup failed. A TypeError means an operation was applied to a value of the wrong type. An IndexError means a sequence was accessed out of bounds. An AttributeError means you called a method or accessed a field that does not exist on that object — very often because the object is unexpectedly None. Experienced engineers develop an almost reflexive mapping from exception type to likely cause, and building that reflex starts with deliberately reading the exception name first, every single time, instead of skimming straight past it to the message.

Multi-frame tracebacks in bigger programs — same rule applies
Traceback (most recent call last):
  File "pipeline.py", line 40, in <module>
    run_pipeline()
  File "pipeline.py", line 31, in run_pipeline
    records = [transform(r) for r in load_records()]
  File "pipeline.py", line 31, in <listcomp>
    records = [transform(r) for r in load_records()]
  File "pipeline.py", line 18, in transform
    return record["id"].strip().upper()
AttributeError: 'int' object has no attribute 'strip'

Here the bottom frame is the real story: record["id"] returned an int, not a str, so calling .strip() on it fails. The fix is not in run_pipeline — it is either in transform (handle non-string IDs) or, more likely, upstream in whatever produced records with an integer id field when a string was expected. A long traceback is not a sign of a complicated bug; it is just Python being generous with context. The fix is almost always concentrated in the last one or two frames.

// Part 02 — print() Debugging and Its Real Limits

print() Is a Legitimate First Tool — Until It Isn't

There is a strain of advice online that treats print() debugging as something amateur engineers do and real engineers don't. That is not true. Sprinkling print() calls to check a value at a specific point is fast, requires no setup, and is genuinely the right tool for a huge fraction of bugs — especially small scripts and quick "what is actually in this variable right now" questions. You already used this technique constantly in the Formatting module (Module 10) when inspecting values with f-strings during development.

print() debugging — fine for a simple, localized bug
def calculate_discount(price, percent):
    print(f"DEBUG: price={price!r}, percent={percent!r}")
    return price * (1 - percent / 100)

The problem is not that print() is wrong — it is that it stops scaling the moment a bug gets even slightly more complex. Four real limits show up constantly:

Where print() debugging genuinely breaks down
1. You have to GUESS which variables matter before you run the code —
   if you guessed wrong, you edit the file and re-run, over and over.

2. It only shows you a snapshot at one instant — it can't show you
   the full call stack, or let you inspect a value that changes across
   many loop iterations without flooding your terminal.

3. You have to remember to delete every debug print before committing —
   and "print statements left in production code" is a real, common
   code review complaint.

4. It can't PAUSE the program and let you explore interactively —
   you only see exactly what you thought to print.
🎯 Pro Tip
A useful personal rule: reach for print() first, for a bug you have a strong guess about, in a small function. The moment you have printed three or four things and still don't understand what is happening — or the bug depends on how a value changes over dozens of loop iterations — stop guessing and reach for a real debugger instead, covered in Part 03 and Part 04 below.

A slightly better middle ground: the logging module

One genuine upgrade from raw print() that costs almost nothing is Python's built-in logging module, which the very next module in this track covers in full depth. For now, it's worth knowing it exists as the natural next step once debug-prints start accumulating in a file you intend to keep.

// Part 03 — pdb, the Python Debugger

pdb — Pausing Your Program and Looking Around

pdb is Python's built-in interactive debugger. Instead of guessing which values to print ahead of time, it pauses your program at a chosen line and drops you into a live prompt where you can inspect any variable in scope, run arbitrary expressions, and step through execution one line at a time. The easiest way to trigger it is the built-in breakpoint() function, available since Python 3.7 — call it anywhere in your code and execution stops there.

Dropping into pdb with breakpoint()
def calculate_discount(price, percent):
    breakpoint()          # execution pauses HERE, on this exact line
    return price * (1 - percent / 100)

calculate_discount(100, 150)
What you land in — the (Pdb) prompt
> app.py(3)calculate_discount()
-> return price * (1 - percent / 100)
(Pdb) 

From that prompt, you are inside a live Python session with full access to every local variable. The core commands you need cover almost everything:

The pdb commands you will actually use
n   (next)      — run the current line, stop at the next one in THIS function
s   (step)      — step INTO a function call on the current line
c   (continue)  — resume normal execution until the next breakpoint (or the end)
p   (print)     — evaluate and print any expression: p price, p percent > 100
l   (list)      — show the source code around the current line
q   (quit)      — abort the debugging session entirely
w   (where)     — show the current call stack, like a live traceback

The distinction between n and s trips people up at first: n treats a function call on the current line as a black box and just moves to the next line in the current function; s actually steps inside that function call, letting you watch its internals execute too. Use n when you trust the function being called and just want to keep moving; use s the moment you suspect the bug is inside it.

pdb.set_trace() — the older, still-common form
import pdb

def calculate_discount(price, percent):
    pdb.set_trace()        # identical effect to breakpoint(), older API
    return price * (1 - percent / 100)
💡 Note
breakpoint() and pdb.set_trace() do the same thing — breakpoint() is just newer, requires no import, and is the version you will see in modern code. You will still run into pdb.set_trace() reading older codebases, so it is worth recognizing both.

Post-mortem debugging — inspecting a crash after the fact

You do not always have to predict where a bug is and place a breakpoint() ahead of time. Running a script with python -m pdb -c continue app.py lets it run normally and drops you into the debugger automatically at the exact moment it crashes, with the full stack still available to inspect — genuinely useful for an intermittent bug you cannot easily reproduce on demand.

// Part 04 — The IDE Debugger

VS Code's Debugger — pdb's Power, Without the Prompt

Back in Module 01, you set up VS Code with the Python extension as this track's recommended editor. That same extension ships a full graphical debugger built directly on top of the same debugging protocol pdb uses conceptually — but instead of typing commands at a text prompt, you click in the left margin next to a line number to set a breakpoint, then run your file with the debugger (the "Run and Debug" panel, or F5) instead of just running it normally.

When execution reaches your breakpoint, the program pauses, and VS Code shows you, all at once, in dedicated panels: every local variable currently in scope and its live value, the full call stack (every function that led to this point, clickable to jump between frames), and a watch panel where you can type any expression and have it continuously re-evaluated as you step through the code — genuinely more convenient than repeatedly typing p commands in pdb for the same variable.

A typical VS Code debugging session, described step by step
1. Click in the gutter to the left of a line number — a red dot appears (a breakpoint).
2. Open "Run and Debug" (or press F5). Your script runs normally until it hits that line.
3. Execution pauses. The Variables panel shows every local variable and its current value.
4. Use the debug toolbar's step controls — visually identical to pdb's n / s / c:
     Step Over  = n        Step Into = s        Continue = c
5. Add an expression to the Watch panel — e.g. "len(cart_items)" —
   and watch it update automatically as you step through the loop.
6. The Call Stack panel shows exactly which functions led here, clickable
   to inspect each frame's own local variables.
🎯 Pro Tip
Conditional breakpoints are the feature that separates a real debugging session from a painful one. Right-click a breakpoint in VS Code and you can attach a condition — for example, only pause when item["id"] == 4821 — instead of stopping on every single iteration of a loop that runs ten thousand times. This is genuinely hard to replicate quickly with pdb alone, and it is the single biggest reason to reach for the IDE debugger once a bug lives inside a loop over real data.

Neither tool makes the other obsolete. pdb works anywhere Python runs — over SSH on a remote server with no graphical interface at all, inside a Docker container, in a CI pipeline. The IDE debugger is faster and more visual for local development. Most working engineers default to the IDE debugger day to day and fall back to pdb the moment they are debugging something running somewhere without a GUI attached.

// Part 05 — Rubber Duck Debugging

Explaining the Bug Out Loud, Line by Line

Rubber duck debugging sounds like a joke the first time you hear it, and it is a genuinely effective technique that experienced engineers still use constantly. The method: get an object — traditionally a literal rubber duck, though a coworker, a pet, or an empty chair works equally well — and explain your code to it, out loud, line by line, as if it understands nothing and you need to walk it through every single decision your code makes.

The mechanism behind why this works is not magic. Reading code silently lets your brain skim — it pattern-matches what it expects the code to say rather than what it actually says, especially in code you wrote yourself and already have a mental model of. Forcing yourself to articulate each line out loud, in full sentences, interrupts that autopilot. You are forced to state your assumptions explicitly — "and then this loop goes through each item in the cart, and for each one it looks up the price in the price dictionary" — and it is extremely common to catch the bug mid-sentence, the moment you say an assumption out loud that turns out to be false the instant you hear yourself say it.

💡 Note
This is exactly why "explaining it to a coworker" so often resolves a bug before the coworker has said a single word back — the value was never really in their answer. It was in the act of being forced to articulate your own reasoning precisely, out loud, instead of letting it stay vague inside your own head. Keep this in mind before interrupting a teammate: try explaining it to an inanimate object first. It costs nothing and works surprisingly often.
// Part 06 — Binary Search Debugging

Cutting the Search Space in Half, Every Time

When a bug lives somewhere in a large amount of code and you genuinely have no idea where to start, the systematic strategy is the same one you would use to find a single wrong entry in a sorted list: don't scan linearly from the top — cut the search space in half, repeatedly. Disable, comment out, or bypass roughly half of the suspect code, and check whether the bug still happens. If it does, the bug is in the half you kept. If it doesn't, the bug is in the half you removed. Repeat on the remaining half.

Binary search debugging on a 200-line pipeline script
# You don't know which of 6 processing steps is corrupting the data.
# Instead of reading all 6 top to bottom, bisect:

def run_pipeline(records):
    records = load_and_clean(records)
    records = deduplicate(records)
    records = enrich_with_metadata(records)   # <- comment out THIS and everything below
    # records = apply_business_rules(records)
    # records = compute_aggregates(records)
    # records = export(records)
    return records

# Check the output after step 2. Correct? The bug is in step 3, 4, 5, or 6 — bisect again.
# Wrong already? The bug is in step 1 or 2 — you've eliminated 4 of 6 steps in one check.

This technique generalizes far beyond commenting out function calls. Git itself has a purpose-built command for exactly this idea at a larger scale: git bisect automatically checks out commits at the midpoint between a known-good and known-bad commit, asks you to test and mark each one good or bad, and converges on the exact commit that introduced a regression in log₂(n) steps rather than checking every commit one by one. If a bug appeared sometime in the last 200 commits and you have no idea which one caused it, git bisect start followed by marking a known-good and known-bad commit will typically find the culprit in 7 or 8 test runs instead of up to 200.

🎯 Pro Tip
Binary search debugging is the right tool specifically when you have no localized hypothesis yet — a large, unfamiliar codebase, or a regression that appeared somewhere in weeks of commits. Once you have narrowed the bug down to a single function or a handful of lines, switch to pdb or the IDE debugger from Parts 03–04 to actually inspect what is happening at that specific point.
// Part 07 — A Full Worked Debugging Session

Start to Finish — Debugging a Broken Discount Function

Here is a realistic bug, debugged the way an engineer actually would, combining several of the techniques above rather than treating them as separate, disconnected tools.

The broken code — a bug report says some customers are seeing negative totals
def apply_discounts(cart_items, discount_codes):
    total = 0
    for item in cart_items:
        price = item["price"]
        for code in discount_codes:
            if code["applies_to"] == item["category"]:
                price = price - (price * code["percent_off"] / 100)
        total += price
    return total

Step 1 — read the exception, if there is one. There isn't. This is worse than a crash: the function runs and returns a plausible-looking number that is simply wrong for some inputs. No traceback to read means Part 01's technique doesn't apply yet — this calls for actively inspecting values, not reading an error.

Step 2 — form a hypothesis before touching a debugger. "Negative totals" for a discount function strongly suggests a discount is being applied more than once, or a percentage is being misread (e.g. 150 meant as a display value being used directly instead of being validated). Rather than guessing blindly, the engineer reaches for breakpoint() right where discounts are applied.

Instrumenting the suspect line with breakpoint()
for item in cart_items:
    price = item["price"]
    for code in discount_codes:
        if code["applies_to"] == item["category"]:
            breakpoint()
            price = price - (price * code["percent_off"] / 100)
    total += price

Step 3 — inspect at the pause. Running the failing input and hitting c to continue through each pause, the engineer types p code at every stop. Two codes both report "applies_to": "electronics" for the same item — one intended as a one-time promo, one as a permanent category discount — and both apply to the exact same item in the same inner loop, stacking on top of each other with no limit.

Step 4 — confirm the hypothesis with a targeted print, once it's cheap to check. With the real cause understood, a quick print(f"item={item['name']!r} matched {len([c for c in discount_codes if c['applies_to'] == item['category']])} discount codes") across the full cart confirms three items match more than one code — the actual bug, not just the case that happened to be paused on.

The fix — only the largest applicable discount should apply, not every matching one
def apply_discounts(cart_items, discount_codes):
    total = 0
    for item in cart_items:
        price = item["price"]
        matching = [c for c in discount_codes if c["applies_to"] == item["category"]]
        if matching:
            best_discount = max(c["percent_off"] for c in matching)
            price = price - (price * best_discount / 100)
        total += price
    return total

Notice the shape of the whole session: read for an exception first (there wasn't one, so that step took five seconds and moved on), form a real hypothesis before touching any tool, use breakpoint() to inspect state at the exact suspect line rather than guessing blindly, and confirm the root cause with a broader check before writing the fix. This is the actual shape of professional debugging — not one technique used in isolation, but a short, disciplined sequence of narrowing steps.

// Part 08 — Real World
💼 What This Looks Like at Work

An On-Call Page at a Denver Delivery-Logistics Company

Scenario — Delivery logistics, Denver · 2 a.m. page

A route-assignment script, running as a scheduled job every night, crashes at 2 a.m. and pages the on-call engineer — a relatively junior hire, three months into the job, first solo page. Tomorrow's delivery routes will not generate unless this is fixed before dispatch opens at 6 a.m.

What the traceback actually says

The paging alert includes this traceback
Traceback (most recent call last):
  File "assign_routes.py", line 88, in <module>
    main()
  File "assign_routes.py", line 71, in main
    routes = build_routes(drivers, stops)
  File "assign_routes.py", line 44, in build_routes
    driver = pick_driver(available_drivers, stop.zone)
  File "assign_routes.py", line 29, in pick_driver
    return sorted(drivers, key=lambda d: d.distance_to(zone))[0]
IndexError: list index out of range

Half-asleep, the engineer's first instinct is to start reading from main() at the top of the traceback. Remembering Part 01 of this module, they force themselves to start at the bottom instead: IndexError: list index out of range, on the line sorted(...)[0]. Indexing [0] into an empty sorted list is the exact cause — drivers was empty when pick_driver ran, meaning every available driver had already been assigned before this particular stop was reached.

Confirming it without guessing

Rather than editing the file blindly and re-running the full nightly job (which takes eleven minutes end to end — an expensive guess-and-check loop at 2 a.m.), the engineer drops a breakpoint() directly above the failing line (Part 03) and re-runs just build_routes against a saved copy of last night's input data. At the pause, p len(available_drivers), p stop.zone, and p [d.zone for d in drivers] confirm it in under a minute: eight drivers were hired last week for a new zone that the stop-assignment logic never accounted for, so every stop in that zone exhausted the (wrongly empty) driver pool for it.

The fix — falling back to the nearest adjacent zone's drivers when a zone has none assigned yet — ships before 4 a.m., routes generate on time, and the engineer writes up the incident the next morning. The lesson they take from it, and repeat to the next new hire on their team months later, is almost word for word Part 01 of this module: read the traceback from the bottom, and confirm your hypothesis with a real pause-and-inspect before editing code at 2 a.m. under pressure.

// Part 09 — Misconceptions

Four Misconceptions About Debugging

✕ ""You should read a traceback from the top, since that's where the program started""
The bottom of the traceback is where the exception actually occurred. The top is simply the earliest frame in the call chain that led there. Reading bottom-up gets you to the actual failing line immediately, instead of working through layers of context you may not need at all.
✕ ""Real engineers use a debugger, not print statements""
print() debugging is a legitimate, fast, zero-setup tool for small, localized bugs, and experienced engineers use it constantly. The real skill is recognizing when a bug has outgrown it — multiple guesses in, a value that changes across many iterations, or a need to explore interactively — and switching to pdb or an IDE debugger at that point.
✕ ""pdb is an old, obsolete tool now that IDEs have built-in debuggers""
pdb still matters specifically because it needs nothing but a Python interpreter — it works over SSH on a remote server, inside a container with no GUI, and in restricted environments where an IDE simply cannot attach. Most engineers prefer the IDE debugger locally and reach for pdb the moment they are debugging somewhere without a graphical interface.
✕ ""A long traceback means a complicated bug""
A long traceback is usually just Python being thorough about context — showing every function call that led to the failure. The actual fix is almost always concentrated in the last one or two frames at the bottom, regardless of how many frames appear above them.
// Part 10 — Interview Prep

5 Interview Questions — With Complete Answers

Walk me through how you would debug a Python script that's throwing an unfamiliar exception.
Start by reading the traceback from the bottom up — that's where the actual exception occurred, with everything above it just showing the call chain that led there. Read the exception type first, since it classifies the category of bug (KeyError means a dict lookup failed, TypeError means a wrong-type operation, and so on). Form a hypothesis about the cause before touching a debugger. If the hypothesis needs confirming, drop a breakpoint() at the suspect line and inspect the actual runtime values with p, rather than guessing and re-running repeatedly.
What's the difference between the n (next) and s (step) commands in pdb?
n (next) executes the current line and stops at the next line within the current function, treating any function call on that line as a black box. s (step) actually steps INTO a function call on the current line, letting you watch that function's internals execute too. Use n when you trust the function being called and just want to keep moving through your own code; use s the moment you suspect the bug lives inside that call.
When would you choose print() debugging over a real debugger, and when is that the wrong call?
print() is the right choice for a small, localized bug where you already have a strong guess about which value is wrong — it's fast and needs no setup. It's the wrong call once you've guessed wrong two or three times, the bug depends on how a value changes across many loop iterations, or you need to explore the call stack interactively — at that point a real debugger (pdb or an IDE debugger) is faster than continuing to edit and re-run the file.
Explain binary search debugging and give an example of when you'd use it.
It's the strategy of repeatedly cutting the search space for a bug in half rather than reading linearly — disable or bypass roughly half the suspect code, check if the bug still happens, and recurse into whichever half still reproduces it. It's the right tool specifically when you have no localized hypothesis yet, like a bug somewhere in a large unfamiliar codebase, or a regression introduced somewhere in weeks of commits — where git bisect applies the exact same idea to find the introducing commit in log-n steps instead of checking every commit one by one.
Why does rubber duck debugging actually work?
Reading your own code silently lets your brain skim and pattern-match what it expects the code to say, rather than what it actually says — especially in code you wrote yourself. Forcing yourself to explain it out loud, line by line, in full sentences, interrupts that autopilot and makes you state your assumptions explicitly. It's extremely common to catch the actual bug mid-sentence, the moment an assumption you say out loud turns out to be false — which is also why explaining a bug to a coworker so often resolves it before they've said anything back.
// Common Mistakes

Debugging Mistakes Beginners Make Constantly

Reading the traceback top-down and investigating the wrong function
The top of a traceback is context, not the cause. Train yourself to jump straight to the bottom frame first, every time, before reading anything above it.
Making a change and re-running without confirming a hypothesis first
Editing code and hoping it fixes the bug, without ever confirming what was actually wrong, is slow and often introduces a second bug on top of the first. Confirm the cause with a print or breakpoint() before writing the fix.
Leaving debug print() statements in committed code
A stray "DEBUG: ..." print left in production code is a common, avoidable code review flag. Delete debug prints before committing, or better, use proper logging (next module) that can be turned on and off without editing code.
Setting a breakpoint() and then continuing past it without inspecting anything
Pausing at a breakpoint is only useful if you actually use the pause — type p on the variables you suspect, run l to see surrounding code, and use w to check the call stack, before hitting c to continue.
Trying to fix an intermittent bug by staring at the code instead of reproducing it
A bug that only happens sometimes needs to be reproduced reliably before it can be debugged effectively. Narrow down the specific input or timing condition that triggers it, or use post-mortem debugging (python -m pdb -c continue) to inspect the exact state at the moment it does happen.
// Error Library

Errors and Symptoms You Will Hit While Debugging — And Exactly Why

bdb.BdbQuit
Cause: Raised when you type "q" (quit) inside a pdb session — this is not a real bug, it is pdb aborting the program because you asked it to stop debugging.
Fix: This is expected behaviour. If you meant to resume normal execution instead of quitting, use "c" (continue) rather than "q".
NameError: name 'breakpoint' is not defined
Cause: breakpoint() was added in Python 3.7. This error means you are running an older Python version.
Fix: Check your version with python3 --version. On anything older than 3.7, use "import pdb; pdb.set_trace()" instead, which does the same thing.
The debugger opens but every variable shows as undefined
Cause: You set a breakpoint on a line that has not executed yet — variables defined later in the function genuinely do not exist yet at that point in execution.
Fix: Use "n" to step forward past the lines that create the variables you want to inspect, or move the breakpoint later in the function.
VS Code says "Just My Code" is hiding a frame you need to see
Cause: By default, VS Code's debugger skips stepping into library/standard-library code to keep the call stack focused on your own code.
Fix: Disable "Just My Code" in the debug configuration (launch.json) if you specifically need to step into a third-party library's internals to understand a bug.
A breakpoint set in VS Code never gets hit
Cause: Common causes: the file being run is not the one you set the breakpoint in (e.g. a stale cached .pyc, or you're running a different entry point), or the line is inside a code path that never actually executes for the input you're testing.
Fix: Confirm which file is actually running (check the debug console's displayed path), and add a temporary print() right above the breakpoint to confirm that line is reached at all.

🎯 Key Takeaways

  • Read a traceback from the bottom up — that is where the actual exception occurred. Everything above it is call-chain context.
  • The exception type (KeyError, TypeError, IndexError, AttributeError...) classifies the bug before you read a single line of your own code.
  • print() debugging is legitimate and fast for small, localized bugs — the skill is recognizing when to switch to a real debugger.
  • breakpoint() (or the older pdb.set_trace()) pauses execution and drops you into an interactive prompt. Core commands: n, s, c, p, l, w.
  • VS Code's built-in debugger gives you the same power visually — breakpoints, a variables panel, a watch panel, and a clickable call stack — plus conditional breakpoints, which are hard to replicate quickly in plain pdb.
  • Rubber duck debugging works by forcing you to state assumptions out loud, interrupting the autopilot skimming that happens when reading code silently.
  • Binary search debugging (and git bisect for regressions) is the right tool when you have no localized hypothesis yet — cut the search space in half repeatedly.
  • A real debugging session combines these tools in sequence: read the error, form a hypothesis, confirm it with a pause-and-inspect, then fix — not one technique in isolation.

What comes next

Module 40 covers logging — why print() genuinely is not logging, the logging module, log levels, and configuring a logger the way real production services do it.

Module 40 → Logging Best Practices
Share

Discussion

0

Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.

Continue with GitHub
Loading...