Look, I’m gonna be straight with you. Go’s error handling is fucking verbose, and debugging without context is like trying to find your keys in a pitch-black room while drunk. You know the drill — you get some generic error message and spend the next 2 hours playing detective, hunting through logs like some sad CSI wannabe.
So I built ctxerrors because I was tired of this bullshit.
The Problem That Pissed Me Off
Picture this: you’re deep in some gnarly Go codebase, everything’s going fine, and then BAM — error. But not just any error, oh no. You get something useful like:
connection refused
That’s it. That’s all you get. WHERE did it fail? WHICH function? WHAT line? Fucking mystery, mate.
You’re left playing 20 questions with your own code:
- Was it the database connection?
- The HTTP client?
- That sketchy third-party API call?
- Your own shitty code from 3 months ago that you wrote at 2 AM?
The Solution (That Actually Works)
So I wrote ctxerrors — a dead simple Go package that automatically captures where your errors happen. Every single error gets tagged with:
- File name — so you know exactly which file
- Line number — pinpoint precision, none of this guessing bullshit
- Function name — because context matters
Here’s how it works:
Basic Usage (For When You Want to Stay Sane)
Instead of:
return errors.New("something broke")
You write:
return ctxerrors.New("something broke")
And instead of getting a useless error message, you get:
something broke [/path/to/your/file.go:42 in main.doStuff]
Boom. No more detective work.
Error Wrapping (The Good Shit)
But wait, it gets better. You can wrap existing errors and build a full trace:
func readConfig() error {
return ctxerrors.New("config file missing")
}
func initDatabase() error {
if err := readConfig(); err != nil {
return ctxerrors.Wrap(err, "failed to read config")
}
return nil
}
func startServer() error {
if err := initDatabase(); err != nil {
return ctxerrors.Wrap(err, "database initialization failed")
}
return nil
}
When this chain of fuckups happens, you get a beautiful error trace:
database initialization failed: failed to read config: config file missing [/path/main.go:12 in main.readConfig] [/path/main.go:17 in main.initDatabase] [/path/main.go:24 in main.startServer]
No more mystery. No more guesswork. Just cold, hard facts about where everything went tits up.
Why This Matters
Look, I get it. There are other error handling libraries out there. But most of them are either:
- Over-engineered pieces of shit that try to do everything and end up doing nothing well
- Academic wankery that looks good in blog posts but sucks in real code
- Enterprise bullshit that requires 47 configuration files and a PhD to use
ctxerrors does exactly one thing: it tells you where your code failed. That’s it. No bells, no whistles, no corporate buzzword bullshit.
Real-World Example (The Stuff That Actually Happens)
Here’s some actual code from a project I’m working on:
func processUserData(userID int) error {
// Validate the user exists
if err := validateUser(userID); err != nil {
return ctxerrors.Wrap(err, "user validation failed")
}
// Fetch their profile
profile, err := fetchUserProfile(userID)
if err != nil {
return ctxerrors.Wrapf(err, "failed to fetch profile for user %d", userID)
}
// Update their settings
if err := updateUserSettings(profile); err != nil {
return ctxerrors.Wrap(err, "settings update failed")
}
return nil
}
When this fails (and it will, because everything fails), I don’t have to dig through logs or add temporary debug statements. I get a clean trace showing exactly what happened and where.
Installation & Usage
Dead simple:
go get github.com/psyb0t/ctxerrors
Then just replace your errors.New() calls with ctxerrors.New() and your fmt.Errorf() wrapping with ctxerrors.Wrap() or ctxerrors.Wrapf().
The package plays nice with Go’s standard error handling — errors.Unwrap(), errors.Is(), and errors.As() all work exactly as you’d expect.
Error Mapping: Stop Foreign Errors From Leaking
Here’s the new toy I added recently. You know the drill — your DAL layer pulls in gorm or pgx or whatever, and now gorm.ErrRecordNotFound is bleeding through every layer of your application. Your service code is importing gorm just to check for “not found.” Your HTTP handlers are doing errors.Is(err, gorm.ErrRecordNotFound). Your business logic is coupled to the fucking ORM. That’s not architecture, that’s a leaky abstraction with a leadership problem.
The fix used to be: write a wrapper in every repository function that translates driver errors into your own business errors before returning. Boring boilerplate, easy to forget, easy to get wrong. So I baked it into ctxerrors instead.
Register a translation map once at startup. Every Wrap / Wrapf call automatically swaps matching driver errors for your own sentinels before wrapping:
package main
import (
"errors"
"github.com/psyb0t/ctxerrors"
"gorm.io/gorm"
)
var (
ErrNotFound = errors.New("not found")
ErrAlreadyExists = errors.New("already exists")
)
func init() {
ctxerrors.SetErrorMap(map[error]error{
gorm.ErrRecordNotFound: ErrNotFound,
gorm.ErrDuplicatedKey: ErrAlreadyExists,
})
}
func GetUser(id int) error {
err := db.First(&user, id).Error // returns gorm.ErrRecordNotFound
if err != nil {
// wrapped err satisfies errors.Is(err, ErrNotFound)
// gorm.ErrRecordNotFound is gone from the chain
return ctxerrors.Wrap(err, "get user")
}
return nil
}Now your service layer checks errors.Is(err, ErrNotFound) and doesn’t have to know gorm exists. Swap the ORM tomorrow, the upper layers don’t notice.
Three functions, that’s the whole API:
SetErrorMap(map[error]error)— replaces the entire map. Best called once at startup.MapError(from, to error)— adds or overwrites a single entry. Good for per-packageinit()registration so each layer wires its own translations without stepping on the others.ClearErrorMap()— wipes everything. Mostly for tests that need a clean slate.
The semantics are deliberately conservative:
- Matching uses
errors.Is, so an already-wrapped foreign error still translates correctly. You can wrap a gorm error five layers deep and it’ll still map. - Single-pass translation — no recursive chains.
A → B → Cisn’t a thing.A → Bstops at B. This avoids the “infinite mapping loop” footgun and keeps the behavior predictable. - nil keys and nil values are ignored, so a partially-built map doesn’t blow up.
- No match? Behavior is unchanged. The error gets wrapped normally with location context, no surprises.
- Thread-safe. The internal map sits behind a
sync.RWMutex, so concurrentWrapcalls during request handling are fine even if you’re registering more mappings at runtime (though you shouldn’t).
This is the kind of thing that sounds small but eliminates an entire category of “shit, I forgot to translate this error” bugs across a codebase. Set the map once, wrap normally everywhere else, and your layers stay decoupled by default.
The Bottom Line
Debugging is already painful enough without having to play guessing games with error locations. ctxerrors gives you the context you need to fix shit fast and get on with your life.
Is it revolutionary? Nah. Is it useful? Absolutely. Will it save you time and frustration? You bet your ass it will.
Bonus: For the Masochists
If you really want to piss off your future self (or your coworkers), you can write absolutely ridiculous nested anonymous function chains like this:
func performStupidlyComplexOperation() error {
// Because apparently we hate ourselves and everyone who reads this code
return func() error {
// Welcome to nested function hell, population: you
if err := func() error {
// This is where sanity comes to die
if err := func() error {
// At this point we're just fucking with people
if err := func() error {
// The beginning of the end
if err := func() error {
// Rock bottom of this shitshow
return ctxerrors.New("step 1 went to shit")
}(); err != nil {
// Step 2: electric boogaloo of failure
return ctxerrors.Wrap(err, "step 2 couldn't handle step 1's bullshit")
}
return nil
}(); err != nil {
// Step 3: the reckoning
return ctxerrors.Wrap(err, "step 3 is having a mental breakdown")
}
return nil
}(); err != nil {
// Step 4: fuck it, we're done trying
return ctxerrors.Wrap(err, "step 4 said fuck this shit")
}
return nil
}(); err != nil {
// The final boss of this clusterfuck
return ctxerrors.Wrap(err, "the entire fucking operation is fucked")
}
return nil
}() // Because we needed one more layer of stupid
}
When this abomination fails, you still get perfect tracing:
the entire fucking operation is fucked: step 4 said fuck this shit: step 3 is having a mental breakdown: step 2 couldn't handle step 1's bullshit: step 1 went to shit [main.go:42 in step1] [main.go:47 in step2] [main.go:53 in step3] [main.go:59 in step4] [main.go:65 in performStupidlyComplexOperation]
Even when your code looks like it was written by someone having a psychotic break, ctxerrors still has your back. That’s the beauty of it.
The Bottom Line
Go check it out: github.com/psyb0t/ctxerrors
And if you find it useful, give it a star or whatever. Or don’t. I’m not your boss.
P.S. — If you’re one of those people who thinks proper error handling is “enterprise bloat,” this package probably isn’t for you. Go back to your fmt.Println() debugging and leave the adults alone.