- Book: The Complete Guide to Go Programming
- Also by me: Pocket Guides for Developers — the 8-book series
- My project: Hermes IDE | GitHub — an IDE for developers who ship with Claude Code and other AI coding tools
- Me: xgabriel.com | GitHub
An Ed25519 signature is 64 bytes. You can paste it into a log line or a JWT and nobody notices. An ML-DSA-65 signature, the middle parameter set of the post-quantum standard NIST published as FIPS 204 on August 13, 2024, is 3,309 bytes. That is about 52 times larger, and the public key grows from 32 bytes to 1,952.
Since Go 1.27.0 shipped on August 19, 2026, you can produce both from the standard library. The release notes say it in one line: "The new crypto/mldsa package implements the post-quantum ML-DSA signature scheme specified in FIPS 204." It all lives in the standard library, so you don't need cgo or a liboqs build.
You will write one small program that signs and verifies a file with ML-DSA-65, prints the key and signature sizes next to Ed25519, and then a benchmark file so the speed half of the trade-off comes from your own CPU instead of someone's blog chart.
What Go 1.27 actually added
Three things in the Go 1.27 release notes matter here:
-
crypto/mldsaimplements ML-DSA as specified in FIPS 204, with the three parameter setsMLDSA44(),MLDSA65()andMLDSA87(). -
crypto/x509"now supports ML-DSA private keys, public keys, and signatures", so ML-DSA keys can live in certificates. -
crypto/tls"now supports ML-DSA signatures in TLS 1.3, with the newMLDSA44,MLDSA65, andMLDSA87SignatureSchemevalues."
The API is small. From the package docs: GenerateKey(params) returns a *PrivateKey, PrivateKey implements crypto.Signer, Verify(pk, message, signature, opts) returns an error (not a bool like ed25519.Verify), and an optional Options{Context: "..."} binds a signature to a purpose. The context can be up to 255 bytes and must match on both sides.
One detail changes how you store keys. PrivateKey.Bytes() returns a 32-byte seed, and PrivateKeySize = 32 is shared by all three parameter sets. The seed does not say which set it belongs to. NewPrivateKey(params, seed) needs you to pass the parameter set back in, so store it next to the seed.

Same job, very different envelope: a 64-byte Ed25519 signature next to a 3,309-byte ML-DSA-65 one.
Sign and verify a file with the standard library
Start a module on Go 1.27 or later:
mkdir pqsig && cd pqsig go mod init example.com/pqsig go version # needs go1.27 or newer Then main.go. It reads a file, signs it with ML-DSA-65 under a context string, verifies it, checks that a tampered copy and a wrong context both fail, and does the same sign/verify with Ed25519:
package main import ( "crypto/ed25519" "crypto/mldsa" "fmt" "log" "os" "slices" ) func main() { if len(os.Args) != 2 { log.Fatal("usage: pqsig <file>") } msg, err := os.ReadFile(os.Args[1]) if err != nil { log.Fatal(err) } sk, err := mldsa.GenerateKey(mldsa.MLDSA65()) if err != nil { log.Fatal(err) } opts := &mldsa.Options{Context: "release-v1"} sig, err := sk.Sign(nil, msg, opts) if err != nil { log.Fatal(err) } pk := sk.PublicKey() if err := mldsa.Verify(pk, msg, sig, opts); err != nil { log.Fatal("ml-dsa verify: ", err) } // One extra byte must break the signature. bad := append(slices.Clone(msg), 0) if mldsa.Verify(pk, bad, sig, opts) == nil { log.Fatal("tampered file verified") } // So must a different context string. other := &mldsa.Options{Context: "release-v2"} if mldsa.Verify(pk, msg, sig, other) == nil { log.Fatal("wrong context verified") } edPub, edPriv, err := ed25519.GenerateKey(nil) if err != nil { log.Fatal(err) } edSig := ed25519.Sign(edPriv, msg) if !ed25519.Verify(edPub, msg, edSig) { log.Fatal("ed25519 verify failed") } row := "%-10s %6d %7d %6d\n" fmt.Printf("%-10s %6s %7s %6s\n", "scheme", "seed", "pubkey", "sig") fmt.Printf(row, pk.Parameters(), len(sk.Bytes()), len(pk.Bytes()), len(sig)) fmt.Printf(row, "Ed25519", len(edPriv.Seed()), len(edPub), len(edSig)) } Run it against any file, for example go run . go.mod.
A few things in there are easy to trip on. The first argument to Sign is an io.Reader, and the docs say it is ignored, so nil is fine; it exists so the method satisfies crypto.Signer. If you need the same signature every time for the same input, there is a separate SignDeterministic(message, opts). And ed25519.GenerateKey(nil) is documented to use a secure random source when passed nil.
The verifying side usually only has the public key bytes. Rebuilding it takes one call:
func verifyRelease(pub, msg, sig []byte) error { pk, err := mldsa.NewPublicKey(mldsa.MLDSA65(), pub) if err != nil { return err } opts := &mldsa.Options{Context: "release-v1"} return mldsa.Verify(pk, msg, sig, opts) } The sizes, side by side
Your program prints lengths taken from the actual byte slices. These are the values the documentation commits to, from the crypto/mldsa constants and the crypto/ed25519 constants:
| Scheme | Private seed | Public key | Signature |
|---|---|---|---|
| Ed25519 | 32 | 32 | 64 |
| ML-DSA-44 | 32 | 1,312 | 2,420 |
| ML-DSA-65 | 32 | 1,952 | 3,309 |
| ML-DSA-87 | 32 | 2,592 | 4,627 |
If your ML-DSA-65 row does not say 32, 1952 and 3309, something is wrong with your toolchain, not with the math.
The private side is the one place ML-DSA does not cost you anything extra in Go: both can be stored as a 32-byte seed. Everything that travels over the wire grows. A public key is 61 times larger and a signature about 52 times larger at the ML-DSA-65 level.
Benchmark both on your machine
Size is fixed by the spec. Speed depends on your CPU, your Go version and the message size, so measure it. Put this next to main.go as sig_test.go. It uses b.Loop, added in Go 1.24, which resets the timer on its first call (so setup outside the loop is not measured) and keeps call results alive so the compiler cannot delete the work:
package main import ( "crypto/ed25519" "crypto/mldsa" "testing" ) var benchMsg = make([]byte, 1024) func BenchmarkMLDSA65Keygen(b *testing.B) { p := mldsa.MLDSA65() for b.Loop() { if _, err := mldsa.GenerateKey(p); err != nil { b.Fatal(err) } } } func BenchmarkMLDSA65Sign(b *testing.B) { sk, err := mldsa.GenerateKey(mldsa.MLDSA65()) if err != nil { b.Fatal(err) } for b.Loop() { _, err := sk.Sign(nil, benchMsg, nil) if err != nil { b.Fatal(err) } } } func BenchmarkMLDSA65Verify(b *testing.B) { sk, err := mldsa.GenerateKey(mldsa.MLDSA65()) if err != nil { b.Fatal(err) } pk := sk.PublicKey() sig, err := sk.Sign(nil, benchMsg, nil) if err != nil { b.Fatal(err) } for b.Loop() { err := mldsa.Verify(pk, benchMsg, sig, nil) if err != nil { b.Fatal(err) } } } func BenchmarkEd25519Keygen(b *testing.B) { for b.Loop() { if _, _, err := ed25519.GenerateKey(nil); err != nil { b.Fatal(err) } } } func BenchmarkEd25519Sign(b *testing.B) { _, priv, err := ed25519.GenerateKey(nil) if err != nil { b.Fatal(err) } for b.Loop() { ed25519.Sign(priv, benchMsg) } } func BenchmarkEd25519Verify(b *testing.B) { pub, priv, err := ed25519.GenerateKey(nil) if err != nil { b.Fatal(err) } sig := ed25519.Sign(priv, benchMsg) for b.Loop() { if !ed25519.Verify(pub, benchMsg, sig) { b.Fatal("verify failed") } } } Run it with allocation stats and a few repetitions so one noisy run does not decide anything:
go test -bench=. -benchmem -count=5 I haven't run this on a reference machine for the post, and I'm not going to quote someone else's ns/op as if it applied to your hardware. Read three columns. ns/op tells you whether signing is a per-request cost you can afford on your hot path. B/op and allocs/op tell you how much garbage each signature makes, which matters if you sign thousands of events per second. Compare the Verify rows too: in most systems the verify count is far higher than the sign count, because one release signature gets checked on every install.

Every byte of signature and public key rides along wherever the signed thing goes.
Where 3,309 bytes starts to hurt
Do the arithmetic for a TLS 1.3 handshake. A typical server sends a leaf certificate and one intermediate. That is two public keys and two certificate signatures, plus the handshake's own CertificateVerify signature from the leaf key. With ML-DSA-65 everywhere, those pieces alone come to 2 × 1,952 + 3 × 3,309 = 13,831 bytes, before names, extensions or anything else in the certificates. With Ed25519 the same pieces are 2 × 32 + 3 × 64 = 256 bytes. Go 1.27 lets you use ML-DSA in TLS 1.3, and the tls.Certificate.PrivateKey docs list ML-DSA as a TLS 1.3 key type, but whether that fits depends on your clients and your network.
The same math applies off the wire. A table of signed audit events, or a JWT signed with EdDSA or ES256 that rides in every request header, was sized around a 64-byte signature. Multiply your row count by 3,245 extra bytes before you migrate a table.
Two gotchas from the package docs before you go to production:
- If you run under the FIPS 140-3 Go Cryptographic Module v1.0.0, ML-DSA is unavailable:
GenerateKey,NewPrivateKey,NewPublicKeyandVerifyreturn an error. It works with module v1.26.0 and later. -
Signalso acceptscrypto.MLDSAMuas the hash function, for the "External μ" mode the release notes mention. That is for signing a pre-hashed μ message representative computed outside the signer. Plain file signing does not need it.
When to reach for ML-DSA now
Post-quantum key exchange and post-quantum signatures solve different problems. Encrypted traffic recorded today can be decrypted later if a large quantum computer appears, which is why Go's TLS stack already has a default set of post-quantum hybrid key exchanges (the 1.27 notes reference the tlsmlkem and tlssecpmlkem switches that control it). A signature only has to resist forgery while someone still relies on it.
So ML-DSA pays off first on signatures that stay trusted for years, like release-signing keys and firmware update roots. Signatures on a 15-minute session token are a weaker case for the extra bytes.
A practical path for those long-lived cases is to sign each artifact with both Ed25519 and ML-DSA-65 and have verifiers accept it only when both signatures pass. The extra cost is one 3,309-byte signature per artifact, which is small next to a binary.
Run the two files above on the machines you actually deploy to. Then you will know what the switch costs before you pick a date for it.
If this was useful
Most of this post is the standard library doing the work: a new package that follows the same crypto.Signer contract as everything else, and testing.B to measure it honestly. The Complete Guide to Go Programming covers that layer in depth: the stdlib's interfaces and how they compose, the runtime, and the concurrency you will need once signing sits on a hot path. It's here, and it pairs with Hexagonal Architecture in Go in the Thinking in Go series.




