How to Add an Image Watermark in Go
Add transparent logo or text watermarks in Go with image/draw, alpha compositing, format choices, troubleshooting, and production tips.
Short answer: decode the source image, copy it into a mutable *image.RGBA, decode a transparent PNG logo, then composite it with draw.Draw(..., draw.Over). Encode the result as PNG or JPEG. Normalizing to RGBA matters because JPEG commonly decodes to *image.YCbCr, while PNG may use several concrete image types.
This guide covers reusable logo watermarks, positioning, resizing, text watermarks, output choices, edge cases, troubleshooting, performance, and production hardening.
1. Set up a Go watermark program
Create a module and place a source image at input.jpg and a transparent logo at logo.png:
mkdir go-watermark && cd go-watermark
go mod init example.com/go-watermark
go get golang.org/x/image/draw
2. Add a transparent PNG logo
This complete program accepts an input path, logo path, output path, and margin. It supports JPEG, PNG, and GIF input, scales an oversized logo down, and chooses PNG or JPEG output from the filename.
package main
import (
"fmt"
"image"
"image/draw"
_ "image/gif"
"image/jpeg"
_ "image/png"
"os"
"path/filepath"
"strings"
xdraw "golang.org/x/image/draw"
)
func decode(path string) (image.Image, error) {
f, err := os.Open(path)
if err != nil { return nil, err }
defer f.Close()
img, _, err := image.Decode(f)
return img, err
}
func fitSize(src image.Point, maxW, maxH int) image.Point {
if src.X <= maxW && src.Y <= maxH { return src }
scale := float64(maxW) / float64(src.X)
if h := float64(maxH) / float64(src.Y); h < scale { scale = h }
w, h := int(float64(src.X)*scale), int(float64(src.Y)*scale)
if w < 1 { w = 1 }; if h < 1 { h = 1 }
return image.Pt(w, h)
}
func watermark(inputPath, logoPath, outputPath string, margin int) error {
src, err := decode(inputPath)
if err != nil { return fmt.Errorf("decode input: %w", err) }
logo, err := decode(logoPath)
if err != nil { return fmt.Errorf("decode logo: %w", err) }
bounds := src.Bounds()
dst := image.NewRGBA(bounds)
draw.Draw(dst, bounds, src, bounds.Min, draw.Src)
size := fitSize(logo.Bounds().Size(), bounds.Dx()/3, bounds.Dy()/3)
resized := image.NewRGBA(image.Rect(0, 0, size.X, size.Y))
xdraw.CatmullRom.Scale(resized, resized.Bounds(), logo, logo.Bounds(), draw.Over, nil)
x := bounds.Max.X - margin - size.X
y := bounds.Max.Y - margin - size.Y
rect := image.Rect(x, y, x+size.X, y+size.Y)
draw.Draw(dst, rect, resized, resized.Bounds().Min, draw.Over)
out, err := os.Create(outputPath)
if err != nil { return fmt.Errorf("create output: %w", err) }
defer out.Close()
switch strings.ToLower(filepath.Ext(outputPath)) {
case ".png":
return jpegOrPNG(out, dst, false)
case ".jpg", ".jpeg":
return jpegOrPNG(out, dst, true)
default:
return fmt.Errorf("output must end in .png, .jpg, or .jpeg")
}
}
func jpegOrPNG(out *os.File, img image.Image, jpegOutput bool) error {
if jpegOutput { return jpeg.Encode(out, img, &jpeg.Options{Quality: 90}) }
return png.Encode(out, img)
}
func main() {
if len(os.Args) != 4 { fmt.Fprintln(os.Stderr, "usage: go run . input.jpg logo.png output.jpg"); os.Exit(2) }
if err := watermark(os.Args[1], os.Args[2], os.Args[3], 24); err != nil {
fmt.Fprintln(os.Stderr, err); os.Exit(1)
}
}
Add the missing PNG encoder import to the import block:
"image/png"
Run it:
go run . input.jpg logo.png output.jpg
go run . input.jpg logo.png output.png
draw.Src copies the decoded source into the mutable RGBA destination. The watermark uses draw.Over, which preserves the logo’s alpha and blends it over the image.
Positioning formulas
- Bottom-right:
x = bounds.Max.X - margin - watermarkWidthandy = bounds.Max.Y - margin - watermarkHeight. - Top-left: start at
bounds.Min + margin. - Top-right: use the right-edge subtraction for x and
bounds.Min.Y + marginfor y. - Bottom-left: use
bounds.Min.X + marginfor x and the bottom-edge subtraction for y.
Use bounds.Min and bounds.Max rather than assuming the image origin is (0,0).
3. Preserve transparency and choose an output format
| Format | Use it when | Trade-off |
|---|---|---|
| PNG | You need transparency, sharp text, or lossless pixels | Usually larger for photographs |
| JPEG | You need small photographic files | No alpha channel and lossy compression |
| WebP | Your consumers support it and size matters | Requires an encoder package |
JPEG flattens transparency against the existing image. If the source has transparent pixels, composite onto an explicit background before JPEG encoding.
4. Add a text watermark
For text, render glyph coverage into an alpha mask, then draw a color through that mask. The following function uses golang.org/x/image/font and a TrueType or OpenType font.
go get golang.org/x/image/font/opentype
func AddText(dst *image.RGBA, text, fontPath string, size float64, col color.Color, x, y int) error {
data, err := os.ReadFile(fontPath)
if err != nil { return err }
fontObj, err := opentype.Parse(data)
if err != nil { return err }
face, err := opentype.NewFace(fontObj, &opentype.FaceOptions{Size: size, DPI: 72, Hinting: font.HintingFull})
if err != nil { return err }
defer face.Close()
mask := image.NewAlpha(dst.Bounds())
drawer := &font.Drawer{Dst: mask, Src: image.White, Face: face, Dot: fixed.P(x, y)}
drawer.DrawString(text)
draw.DrawMask(dst, dst.Bounds(), image.NewUniform(col), image.Point{}, mask, image.Point{}, draw.Over)
return nil
}
Import image/color, os, image/draw, golang.org/x/image/font, golang.org/x/image/font/opentype, and golang.org/x/image/math/fixed. The y-coordinate is the text baseline, so account for the font ascent when placing it near an edge. A practical alternative is to render text once to a transparent PNG and reuse the logo path.
5. Repeat a watermark as a grid
For stock previews, loop over x and y positions and call draw.Draw for each rectangle. Rotate the watermark before the loop if you need a diagonal pattern. A dedicated package such as go-imagewatermark/v3 provides higher-level controls for opacity, alignment, rotation, patterns, and batch processing. Review its current API and license before adoption.
6. Standard library versus higher-level options
| Option | Best fit | Consider |
|---|---|---|
| image + image/draw | Small dependency surface and logo overlays | You implement text layout, rotation, grids, and resizing policy |
| golang.org/x/image/draw | Higher-quality scaling and extra draw helpers | External module versioning |
| go-imagewatermark/v3 | Opacity, alignment, rotation, patterns, and batch helpers | Review API stability and license compatibility |
| Hosted image transforms | Overlay processing without shipping pixel code | Service pricing, limits, latency, and vendor coupling |
The cited projects do not publish a common watermark benchmark. Measure your own image dimensions, concurrency, output format, and quality settings.
7. Edge cases and production checklist
- Small images: clamp logo dimensions and margin so the destination rectangle stays inside the image.
- EXIF orientation: normalize camera orientation before watermarking when display orientation matters.
- Animated GIFs:
image.Decodeprocesses one frame. Use an animation-aware package to watermark every frame. - Color profiles: validate representative JPEGs in the viewers your users rely on.
- Untrusted uploads: limit request size and decoded dimensions before allocating large buffers.
- Alpha halos: avoid repeated JPEG intermediates and use a premultiplied-alpha-aware workflow.
- Idempotency: always watermark from the original source to avoid accumulating opacity and compression artifacts.
8. Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
image: unknown format |
Decoder package is not registered | Add blank imports for JPEG, PNG, and GIF. |
| Logo appears opaque | Logo was encoded as JPEG or copied with draw.Src |
Keep the logo as PNG and composite with draw.Over. |
| Logo is off-canvas | Coordinates ignored non-zero bounds or logo is oversized | Compute from bounds.Min/Max and clamp the fitted size. |
| Output is black or empty | Destination was not initialized or output was not closed | Create image.NewRGBA, draw the source first, and close the file. |
| Text does not render | Font parsing failed or baseline is outside the image | Check the font path, return parse errors, and place the baseline using font metrics. |
| Memory spikes | Large decoded dimensions and several full-size buffers | Validate dimensions, process one image at a time, and bound concurrency. |
9. Performance, reliability, and cost
Decoding and encoding usually dominate CPU time. Resizing and tiled draws add work proportional to pixel count. Reuse parsed fonts and immutable watermark assets across requests, but never share mutable destination images between goroutines. Bound concurrency to available memory and CPU.
For reliable services, write to a temporary file and rename only after a successful encode. Attach request IDs to errors and retry at the job layer when the source is durable. Local processing has no service fee; hosted transforms add transfer latency and provider charges. Benchmark with your actual dimensions and formats.
10. Or skip the browser setup
If your workflow starts with web pages rather than local image files, ScreenshotNeo returns a PNG, JPEG, WebP, or PDF from one GET request. It accepts cookie and consent banners before capture and removes more than 60 known consent platforms, newsletter popups, and chat widgets. Bot checks, blank pages, timeouts, failed loads, and cache hits are not billed; response headers identify the page verdict and billing status. Its MCP server provides take_screenshot, get_page_info, and capture_pdf for Claude, Cursor, and other MCP clients.
See the ScreenshotNeo API docs for capture options:
curl -G "https://api.screenshotneo.com/v1/shot" -d access_key=YOUR_API_KEY --data-urlencode url=https://stripe.com -o shot.webp
import requests
r = requests.get("https://api.screenshotneo.com/v1/shot", params={"access_key": "YOUR_API_KEY", "url": "https://stripe.com"}, timeout=90)
open("shot.webp", "wb").write(r.content)
const q = new URLSearchParams({ access_key: 'YOUR_API_KEY', url: 'https://stripe.com' });
const res = await fetch(`https://api.screenshotneo.com/v1/shot?${q}`);
1,000 screenshots per month are free with no card. Paid plans start at $5 for 3,000 shots. Create a free ScreenshotNeo account.
11. FAQ
Can I watermark a JPEG without converting it to PNG first?
Yes. Decode it, draw onto RGBA, composite the logo, and JPEG-encode with an explicit quality. PNG is needed for a transparent final background, not for the watermark input.
Why use draw.Over instead of draw.Src?
Over performs source-over alpha composition. Src replaces destination pixels and can erase the image where the watermark is transparent.
How do I make the watermark harder to remove?
Use a low-opacity tiled pattern or place marks across important regions. Test readability and accessibility on representative images.
Should I use a package?
The standard library is enough for a logo overlay. Choose a package when rotation, patterns, text layout, or batch helpers justify the dependency.


