Back to blog
Engineering13 min read

Prompting for Go and Rust

Why AI-written Go usually compiles but misbehaves, why AI-written Rust often won't compile at all, and how to prompt each language for code that actually holds up.

NH
Nafiul Hasan

TL;DR: Generic coding prompts fail Go and Rust in opposite ways. AI-written Go usually compiles and quietly does the wrong thing: discarded errors, leaked goroutines, un-idiomatic style. AI-written Rust often won't compile at all, because the borrow checker rejects a model's favorite shortcuts, .unwrap() and .clone(), instead of a real fix.

Why do the same prompts fail differently in Go and Rust?

Ask a general-purpose coding assistant for "a Go function that reads a file and returns its contents" or "a Rust function that parses a config file," and both will very likely produce something that looks correct. The difference shows up at the point where correctness is actually checked.

Go's compiler checks types and syntax. It has nothing to say about whether you handled an error, whether a goroutine can ever stop, or whether you followed the language's own conventions. So a plausible-looking answer compiles, ships, and fails later, often in a way that's hard to trace back to the prompt that produced it.

Rust's compiler checks something Go's doesn't: whether every reference in your program is valid for as long as something is holding onto it. That's the borrow checker, and it rejects a huge amount of code that would compile fine in almost any other language, including code an AI model writes with total confidence. The practical result is the framing worth carrying into every Go or Rust prompt you write: in Go, generated code compiles and is subtly wrong; in Rust, it often doesn't compile at all, which is the safer failure. A build that refuses to finish is a problem you find on your own machine. A build that succeeds and misbehaves is a problem your users find for you.

Where AI-generated Go and Rust actually break, and what the standard tooling catches
FeatureGoRust
Most common shortcut a model reaches forSkip the error check, or wrap without %w.unwrap() or .clone() to silence the compiler
Does that shortcut usually compile?YesOften, no
Where the mistake is caughtAt runtime, sometimes in productionAt compile time, before it ships
Standard-toolchain coverage of the shortcut itselfgo vet has no analyzer for a discarded return valueclippy has a dedicated unwrap_used lint, but it ships Allow by default
One thing the standard toolchain DOES catch nearbygo vet's lostcancel check: a forgotten context cancel()The borrow checker itself, on every single build

What actually goes wrong when AI writes Go?

Four patterns show up often enough to name.

Ignored or unwrapped errors. Go's own guidance on the subject is direct: error is a plain interface type, os.Open returns a non-nil error when it fails, and the baseline pattern is checking it immediately:

f, err := os.Open("filename.ext")
if err != nil {
    log.Fatal(err)
}
// do something with the open *File f

That pattern is easy to state and easy to skip. A model asked to "read a config file" will often produce the happy path and quietly drop the error check, or check it without adding context. Since Go 1.13, fmt.Errorf supports a %w verb specifically for this: according to its own documentation, "If the format specifier includes a %w verb with an error operand, the returned error will implement an Unwrap method returning the operand." Wrapping with %w instead of %v is what lets a caller later use errors.Is or errors.As to check what actually went wrong, several layers up the call stack. Generated code frequently uses %v or a bare string concatenation instead, which throws that ability away.

Goroutines with no way to stop. A goroutine started without a cancellation path keeps running even after the code that launched it no longer needs the result. The standard library's own context package example for WithCancel states the point plainly: "This example demonstrates the use of a cancelable context to prevent a goroutine leak." The same doc adds: "Canceling this context releases resources associated with it, so code should call cancel as soon as the operations running in this Context complete." A model that writes the goroutine but skips the context.Context parameter, or accepts one and never checks ctx.Done(), has written code that leaks by default. Go's own go vet tool has a narrow but real check for one version of this mistake: its lostcancel analyzer's own one-line description reads "check cancel func returned by context.WithCancel is called". It catches the forgotten cancel() call specifically; it does not catch a goroutine that ignores ctx.Done() entirely, which is the more common generated-code version of the same bug.

Unbuffered channels that block forever. The Go language specification is explicit about channel capacity: "If the capacity is zero or absent, the channel is unbuffered and communication succeeds only when both a sender and receiver are ready." Code that sends on an unbuffered channel with no active receiver, or that assumes a send "just works" the way it would with a buffered queue, deadlocks instead of erroring gracefully. This is a common seam in generated fan-out/fan-in code, where the model gets the channel type right but not the guarantee about who has to be listening when.

Non-idiomatic style. Go is unusually opinionated about formatting. Gofmt's own introduction describes formatted code as easier to write, since you never have to worry about minor formatting concerns while working, easier to read, easier to maintain, and, in its word, "uncontroversial" — and it's close to universal in real Go codebases. Generated code that compiles but ignores gofmt conventions, or reaches for a third-party dependency where the standard library already has an equivalent, is non-idiomatic in a way that's obvious to any Go reviewer even though nothing about it is wrong.

What actually goes wrong when AI writes Rust?

The pattern is different because the compiler itself is stricter.

Reaching for .unwrap() instead of handling the error. Rust's own documentation on recoverable errors explains what the method actually does: "If the Result value is the Ok variant, unwrap will return the value inside the Ok. If the Result is the Err variant, unwrap will call the panic! macro for us." That's fine in a quick script and a real defect in library code, because it turns every failure into a crash instead of a value the caller can act on. Clippy, Rust's own linter, ships a dedicated lint for exactly this, unwrap_used, added in version 1.45.0. Its own documentation is direct about what it looks for: "Checks for .unwrap() or .unwrap_err() calls on Results and .unwrap() call on Options." But that same documentation places the lint in the restriction group at the allow level by default, meaning it stays silent unless a project explicitly turns it on, which most generated code never does. Clippy's own example shows the fix directly:

// generated, and a real defect in a library:
result.unwrap();

// Clippy's own suggested alternative:
result.expect("more helpful message");

// or, propagating the error to the caller instead:
result?;

Reaching for .clone() instead of fixing ownership. The Clone trait's own documentation opens with "A common trait that allows explicit creation of a duplicate value." and adds a distinction worth remembering: "Differs from Copy in that Copy is implicit and an inexpensive bit-wise copy, while Clone is always explicit and may or may not be expensive." A model that hits a borrow-checker error will often insert .clone() at exactly the point the compiler complained, without knowing or saying whether that clone is a cheap copy of an integer or a deep, expensive duplication of a large structure. The error disappears; whether the fix was free is a separate question the model never answered.

Lifetimes. Rust's chapter on validating references states the actual goal plainly: "The main aim of lifetimes is to prevent dangling references, which, if they were allowed to exist, would cause a program to reference data other than the data it’s intended to reference." The same chapter's own minimal failing example is worth knowing, because it's the shape a lot of AI-generated Rust collapses into once real ownership is involved:

fn main() {
    let r;
    {
        let x = 5;
        r = &x;
    }
    println!("r: {r}");
}

Rust's own explanation of why this fails: "The Rust compiler has a borrow checker that compares scopes to determine whether all borrows are valid." x goes out of scope at the closing brace; r outlives it; the reference would dangle, so the compiler rejects the program before it can run. A model reproducing a pattern that's fine in a garbage-collected language will hit this constantly, and the fix is almost never a lifetime annotation, it's usually a restructure so the data outlives every reference to it.

Async. Rust's own async chapter is careful to separate two things a model regularly conflates: the language-level async/await syntax, and "the third-party crates that implement asynchronous runtimes: code that manages and coordinates the execution of asynchronous operations." Rust's standard library defines the syntax and the Future trait; it does not ship a runtime. Code that assumes async fn main() runs on its own, or mixes assumptions from one runtime's conventions into a project built on a different one, is a frequent tell that a model wrote async Rust the way it would write async code in a language where the runtime is bundled in.

Which failure mode should worry you more: Go's or Rust's?

Go's, by a wide margin, and the reason is entirely about when you find out. A Rust program that violates ownership or a lifetime rule almost always fails on your machine, during a build, before a single user has touched it. A Go program that discards an error, leaks a goroutine, or blocks on a channel usually compiles cleanly and ships, and the mistake surfaces later as a support ticket, a memory graph that only grows, or a request that hangs for no visible reason.

That's not an argument that Rust is strictly better for every project; it's an argument about what to prompt for and, more importantly, what to review for. When a model hands you Go, the code passing go build tells you nothing about whether it's correct. When a model hands you Rust, a clean compile is doing real work for you already; your review effort should go toward the parts the compiler can't see: whether an .unwrap() slipped past a lint you never enabled, and whether a .clone() quietly made something slower.

How do you prompt for idiomatic Go instead of merely-compiling Go?

Four additions change the output more than any amount of "write clean code" boilerplate:

  1. State your actual Go version. The standard library keeps adding functions that make hand-rolled patterns obsolete; the errors package's own documentation shows a generic AsType function "added in go1.26.0" that finds a typed error anywhere in a wrapped chain. A model trained before that addition doesn't know it exists and will write a longer type-switch instead.
  2. Ask explicitly for %w, not %v, in every fmt.Errorf call that wraps an error, and ask for a one-line justification of why each error is or isn't wrapped.
  3. Require a context.Context as the first parameter on anything that starts a goroutine, and ask the model to show where ctx.Done() is checked inside the loop, not just where the parameter is declared.
  4. Ask it to run its own output through gofmt conventions mentally and flag any place it reached for a third-party package where the standard library already has an equivalent.
You are writing idiomatic Go for Go 1.26. Requirements:
- Check every returned error immediately; never discard one.
- Wrap errors with fmt.Errorf and %w, not %v, unless this is a leaf error.
- Any goroutine you start must accept a context.Context and check ctx.Done()
  inside its loop, not just at the top of the function.
- Before adding a third-party dependency, state whether the standard library
  already covers this, and name the package if so.
- Format the output as gofmt would.

How do you get Rust that compiles on the first try?

The goal isn't avoiding a compile error, since a genuine one is doing you a favor. The goal is making sure the model doesn't paper over one.

You are writing Rust for the 2024 edition. Requirements:
- Do not use .unwrap() or .expect() in any function that isn't a test.
  Propagate errors with ? or return a Result instead.
- If you reach for .clone() to resolve a borrow-checker error, state in a
  comment whether the cloned type is cheap (a small Copy-able value) or
  potentially expensive, and whether restructuring ownership would avoid
  the clone entirely.
- If this code is async, state which runtime you're assuming (e.g. tokio)
  and don't mix its conventions with another runtime's.
- If the code doesn't compile, show the compiler's own error before
  proposing a fix, rather than silently changing the approach.

That last line matters more than it looks. A model that shows you the actual borrow-checker error, the one the compiler produced, gives you something to reason about. A model that just tries a different approach until something compiles can land on .clone() or .unwrap() without ever telling you it hit a wall first.

Free Chrome Extension

Stop rewriting prompts. Start shipping.

Works with ChatGPT, Claude, Gemini, Grok, Midjourney, Ideogram, Veo3 & Kling. 4.8★ on the Chrome Web Store.

Create An Account

If you're prompting inside a REPL-style environment instead of a plain editor, the failure modes shift again: see prompting inside Jupyter notebooks for what changes when the code runs cell by cell instead of as a whole program. For the review step that catches what a language-specific prompt still misses, this framework for prompting a genuinely useful code review is the natural next step, and why AI sometimes rewrites code you didn't ask it to touch covers a failure mode that hits Go and Rust prompts just as often as any other language. If you're feeding this into a PR, prompting for commit messages and PR descriptions and what actually belongs in a CLAUDE.md cover the two places a language-specific instruction like this one is worth saving permanently, instead of retyping it into every prompt.

Frequently asked questions

Free Chrome Extension

Stop rewriting prompts. Start shipping.

Works with ChatGPT, Claude, Gemini, Grok, Midjourney, Ideogram, Veo3 & Kling. 4.8★ on the Chrome Web Store.

Create An Account