Access logs are full of percent-encoded request paths. Here’s how to extract, decode, and clean them with awk, grep, sed, and a short Python decode — streaming line by line so even multi-gigabyte logs never touch memory.
In the default combined log format used by both Nginx and Apache, each line contains the request wrapped in quotes. A typical entry looks like this:
203.0.113.5 - - [08/Jul/2026:10:14:22 +0000] "GET /search?q=caf%C3%A9%20bar&sort=price HTTP/1.1" 200 5123 "-" "Mozilla/5.0"
The request path there — /search?q=caf%C3%A9%20bar&sort=price — is percent-encoded: %C3%A9 is é in UTF-8 and %20 is a space. To analyze what users actually searched for, you need to pull that field out and decode it.
In the combined format the quoted request is one field, and the URL is the second token inside it (after the method). awk pulls it out cleanly:
# $7 is the URL in the standard combined log format
awk '{print $7}' access.log
If your log_format is customized, the field number changes — count the space-delimited fields in one line first. To isolate just the query string, split on the ?:
awk '{print $7}' access.log | awk -F'?' '{print $2}'
On a large log, narrow to the lines you care about first — filtering is far cheaper than decoding every row. grep streams the file and emits only matches:
# Only requests to /search that contain encoding
grep -E '"GET /search\?[^"]*%[0-9A-Fa-f]{2}' access.log
# Only lines with a doubly-encoded sequence (%25 followed by hex)
grep -E '%25[0-9A-Fa-f]{2}' access.log
That second pattern is a quick way to surface double-encoding bugs or evasion attempts hiding in your traffic.
The most reliable decoder is a one-line Python filter — it applies the same RFC 3986 rules a browser does and handles multi-byte UTF-8 correctly, which raw sed substitutions do not:
# Decode standard percent-encoding, line by line
awk '{print $7}' access.log \
| python3 -c "import sys,urllib.parse; [print(urllib.parse.unquote(l),end='') for l in sys.stdin]"
For query data that came from HTML forms, where + means space, swap unquote for unquote_plus:
| python3 -c "import sys,urllib.parse; [print(urllib.parse.unquote_plus(l),end='') for l in sys.stdin]"
A pure-sed approach can handle simple ASCII cases (sed 's/%20/ /g'), but it breaks on multi-byte characters like %C3%A9, so reserve it for known, single-byte substitutions only.
Chain the decode into a standard count-and-sort to get a frequency table of decoded paths — the whole pipeline stays streaming, so memory use stays flat regardless of file size:
awk '{print $7}' access.log \
| python3 -c "import sys,urllib.parse; [print(urllib.parse.unquote(l),end='') for l in sys.stdin]" \
| sort | uniq -c | sort -rn | head -20
That gives you the twenty most-requested decoded URLs. Because every stage reads a line and passes it on, a 20 GB log processes in constant memory — the thing a browser-based tool can’t do.
For a single suspicious URL, or a handful you want to inspect interactively, the shell pipeline is overkill. Paste them into our URL decoder — it shows the decoded value, handles 50+ charsets for legacy data, and offers recursive decoding for the double-encoded evasion sequences you flagged in Step 2. Logs go through the shell; one-off lookups go through the browser.
application/x-www-form-urlencoded convention (+ for space) that unquote_plus handles. url.spec.whatwg.orgé is logged as %C3%A9 and needs a UTF-8-aware decoder, not a plain sed substitution. rfc-editor.org/rfc/rfc3629Honest scope: the field numbers ($7) assume the default combined log format — a custom log_format changes them, so verify against one of your own log lines first. The shell commands themselves are standard Unix tooling, not defined by any web standard.
Because tools like awk, grep, and sed stream a file line by line, they never load the whole thing into memory. A multi-gigabyte access log that would freeze a browser tab is processed in a steady, constant-memory pass on the command line. Use a browser decoder for a handful of URLs; use the shell for logs.
Pipe the extracted paths through a small decoder. A common trick is printf with the %b format after converting %XX to \xXX, but the most reliable approach is a short Python one-liner: python3 -c "import sys,urllib.parse; [print(urllib.parse.unquote(l),end='') for l in sys.stdin]". It decodes each line using the same rules as a browser.
Only for query strings. Request paths in a log are RFC 3986 paths, where + is a literal plus. If you’re decoding the query part of logged URLs and they came from HTML form submissions, then + means space and you’d use a form-aware decode (Python’s unquote_plus). Decoding the path with plus-as-space would corrupt any real plus signs.
In the default combined log format the request is the fifth space-delimited field, quoted — e.g. "GET /path?q=1 HTTP/1.1". awk '{print $7}' pulls the URL out of that quoted request on a standard combined-format line. Adjust the field number if your log_format is customized.
Decoding only reveals the text — it never executes anything — so it’s safe and often necessary for security review. Attackers frequently double-encode payloads (like %252e%252e%252f for ../) to evade filters, so decode recursively when auditing suspicious log entries to see the real request.
Written and maintained by the urlencodedecode.com team. Every technical claim on this page is verified against primary sources — the RFCs (3986, 3629, 4648, 7578), the WHATWG URL Standard, and official vendor or language documentation — rather than second-hand summaries. When a source contradicts a common assumption, we follow the source and note the discrepancy. Corrections: contactus@urlencodedecode.com.