diff --git a/.github/workflows/linux-integration-test.yml b/.github/workflows/linux-integration-test.yml
index b29e7f1..e0a7095 100644
--- a/.github/workflows/linux-integration-test.yml
+++ b/.github/workflows/linux-integration-test.yml
@@ -17,13 +17,13 @@ jobs:
 
     steps:
       - name: Set up Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v5
         with:
           go-version: ${{ matrix.go-version }}
         id: go
 
       - name: Check out code into the Go module directory
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
 
       - name: Create a network namespace for privileged tests
         run: sudo ip netns add unpriv0
diff --git a/.github/workflows/linux-test.yml b/.github/workflows/linux-test.yml
index aa7f76b..9fe0b11 100644
--- a/.github/workflows/linux-test.yml
+++ b/.github/workflows/linux-test.yml
@@ -12,18 +12,18 @@ jobs:
   build:
     strategy:
       matrix:
-        go-version:  ["1.21", "1.22", "1.23"]
+        go-version: ["1.21", "1.22", "1.23"]
     runs-on: ubuntu-latest
 
     steps:
       - name: Set up Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v5
         with:
           go-version: ${{ matrix.go-version }}
         id: go
 
       - name: Check out code into the Go module directory
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
 
       - name: Create a network namespace for unprivileged tests
         run: sudo ip netns add unpriv0
diff --git a/.github/workflows/macos-test.yml b/.github/workflows/macos-test.yml
index c305b13..8838130 100644
--- a/.github/workflows/macos-test.yml
+++ b/.github/workflows/macos-test.yml
@@ -17,13 +17,13 @@ jobs:
 
     steps:
       - name: Set up Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v5
         with:
           go-version: ${{ matrix.go-version }}
         id: go
 
       - name: Check out code into the Go module directory
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
 
       - name: Run tests
         run: go test -v -race ./...
diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml
index 319eeea..d788c60 100644
--- a/.github/workflows/static-analysis.yml
+++ b/.github/workflows/static-analysis.yml
@@ -17,13 +17,13 @@ jobs:
 
     steps:
       - name: Set up Go
-        uses: actions/setup-go@v3
+        uses: actions/setup-go@v5
         with:
           go-version: ${{ matrix.go-version }}
         id: go
 
       - name: Check out code into the Go module directory
-        uses: actions/checkout@v3
+        uses: actions/checkout@v4
 
       - name: Install staticcheck
         run: go install honnef.co/go/tools/cmd/staticcheck@latest
diff --git a/debug.go b/debug.go
index d39d66c..62cb03e 100644
--- a/debug.go
+++ b/debug.go
@@ -2,10 +2,15 @@ package netlink
 
 import (
 	"fmt"
+	"io"
 	"log"
 	"os"
 	"strconv"
 	"strings"
+	"syscall"
+	"unsafe"
+
+	"github.com/mdlayher/netlink/nlenc"
 )
 
 // Arguments used to create a debugger.
@@ -23,47 +28,188 @@ func init() {
 
 // A debugger is used to provide debugging information about a netlink connection.
 type debugger struct {
-	Log   *log.Logger
-	Level int
+	Log    *log.Logger
+	Level  int
+	Format string
+}
+
+func panicf(format string, a ...interface{}) {
+	panic(fmt.Sprintf(format, a...))
+}
+
+/*
+	nlmsgFprintf - print netlink message to file
+	- Based on https://git.netfilter.org/libmnl/tree/src/nlmsg.c
+	- This function prints the netlink header to a file handle.
+	- It may be useful for debugging purposes. One example of the output
+	- is the following:
+
+----------------        ------------------
+|  0000000040  |        | message length |
+| 00016 | R-A- |        |  type | flags  |
+|  1289148991  |        | sequence number|
+|  0000000000  |        |     port ID    |
+----------------        ------------------
+| 00 00 00 00  |        |  extra header  |
+| 00 00 00 00  |        |  extra header  |
+| 01 00 00 00  |        |  extra header  |
+| 01 00 00 00  |        |  extra header  |
+|00008|--|00003|        |len |flags| type|
+| 65 74 68 30  |        |      data      |       e t h 0
+----------------        ------------------
+
+	*
+	* This example above shows the netlink message that is send to kernel-space
+	* to set up the link interface eth0. The netlink and attribute header data
+	* are displayed in base 10 whereas the extra header and the attribute payload
+	* are expressed in base 16. The possible flags in the netlink header are:
+	*
+	* - R, that indicates that NLM_F_REQUEST is set.
+	* - M, that indicates that NLM_F_MULTI is set.
+	* - A, that indicates that NLM_F_ACK is set.
+	* - E, that indicates that NLM_F_ECHO is set.
+	*
+	* The lack of one flag is displayed with '-'. On the other hand, the possible
+	* attribute flags available are:
+	*
+	* - N, that indicates that NLA_F_NESTED is set.
+	* - B, that indicates that NLA_F_NET_BYTEORDER is set.
+*/
+func nlmsgFprintfHeader(fd io.Writer, nlh Header) {
+	fmt.Fprintf(fd, "----------------\t------------------\n")
+	fmt.Fprintf(fd, "|  %010d  |\t| message length |\n", nlh.Length)
+	fmt.Fprintf(fd, "| %05d | %s%s%s%s |\t|  type | flags  |\n",
+		nlh.Type,
+		ternary(nlh.Flags&Request != 0, "R", "-"),
+		ternary(nlh.Flags&Multi != 0, "M", "-"),
+		ternary(nlh.Flags&Acknowledge != 0, "A", "-"),
+		ternary(nlh.Flags&Echo != 0, "E", "-"),
+	)
+	fmt.Fprintf(fd, "|  %010d   |\t| sequence number|\n", nlh.Sequence)
+	fmt.Fprintf(fd, "|  %010d  |\t|     port ID    |\n", nlh.PID)
+	fmt.Fprintf(fd, "----------------\t------------------\n")
 }
 
-// newDebugger creates a debugger by parsing key=value arguments.
-func newDebugger(args []string) *debugger {
-	d := &debugger{
-		Log:   log.New(os.Stderr, "nl: ", 0),
-		Level: 1,
+// nlmsgFprintf checks a single Message for netlink errors.
+func nlmsgFprintf(fd io.Writer, m Message) {
+	colorize := true
+	var hasHeader bool
+	nlmsgFprintfHeader(fd, m.Header)
+	switch {
+	case m.Header.Type == Error:
+		hasHeader = true
+	case m.Header.Type == Done && m.Header.Flags&Multi != 0:
+		if len(m.Data) == 0 {
+			return
+		}
+	default:
+		// Neither, nothing to do.
 	}
 
-	for _, a := range args {
-		kv := strings.Split(a, "=")
-		if len(kv) != 2 {
-			// Ignore malformed pairs and assume callers wants defaults.
-			continue
+	// Errno occupies 4 bytes.
+	const endErrno = 4
+	if len(m.Data) < endErrno {
+		return
+	}
+
+	c := nlenc.Int32(m.Data[:endErrno])
+	if c != 0 {
+		b := m.Data[0:4]
+		fmt.Fprintf(fd, "| %.2x %.2x %.2x %.2x  |\t",
+			0xff&b[0], 0xff&b[1],
+			0xff&b[2], 0xff&b[3])
+		fmt.Fprintf(fd, "|  extra header  |\n")
+	}
+
+	// Flags indicate an extended acknowledgement. The type/flags combination
+	// checked above determines the offset where the TLVs occur.
+	var off int
+	if hasHeader {
+		// There is an nlmsghdr preceding the TLVs.
+		if len(m.Data) < endErrno+nlmsgHeaderLen {
+			return
 		}
 
-		switch kv[0] {
-		// Select the log level for the debugger.
-		case "level":
-			level, err := strconv.Atoi(kv[1])
-			if err != nil {
-				panicf("netlink: invalid NLDEBUG level: %q", a)
-			}
+		// The TLVs should be at the offset indicated by the nlmsghdr.length,
+		// plus the offset where the header began. But make sure the calculated
+		// offset is still in-bounds.
+		h := *(*Header)(unsafe.Pointer(&m.Data[endErrno : endErrno+nlmsgHeaderLen][0]))
+		off = endErrno + int(h.Length)
 
-			d.Level = level
+		if len(m.Data) < off {
+			return
 		}
+	} else {
+		// There is no nlmsghdr preceding the TLVs, parse them directly.
+		off = endErrno
 	}
 
-	return d
-}
+	data := m.Data[off:]
+	for i := 0; i < len(data); {
+		// Make sure there's at least a header's worth
+		// of data to read on each iteration.
+		if len(data[i:]) < nlaHeaderLen {
+			break
+		}
+
+		// Extract the length of the attribute.
+		l := int(nlenc.Uint16(data[i : i+2]))
+		// extract the type
+		t := nlenc.Uint16(data[i+2 : i+4])
+		// print attribute header
+		if colorize {
+			fmt.Fprintf(fd, "|\033[1;31m%05d|\033[1;32m%s%s|\033[1;34m%05d\033[0m|\t",
+				l,
+				ternary(t&syscall.NLA_F_NESTED != 0, "N", "-"),
+				ternary(t&syscall.NLA_F_NET_BYTEORDER != 0, "B", "-"),
+				t&attrTypeMask)
+			fmt.Fprintf(fd, "|len |flags| type|\n")
+		} else {
+			fmt.Fprintf(fd, "|%05d|%s%s|%05d|\t",
+				l,
+				ternary(t&syscall.NLA_F_NESTED != 0, "N", "-"),
+				ternary(t&syscall.NLA_F_NET_BYTEORDER != 0, "B", "-"),
+				t&attrTypeMask)
+			fmt.Fprintf(fd, "|len |flags| type|\n")
+		}
+
+		nextAttr := i + nlaAlign(l)
+
+		// advance the pointer to the bytes after the header
+		i += nlaHeaderLen
+
+		// Ignore zero-length attributes.
+		if l == 0 {
+			continue
+		}
+		// If nested check the next attribute
+		if t&syscall.NLA_F_NESTED != 0 {
+			continue
+		}
+
+		// Print the remaining attributes bytes
+		for ; i < nextAttr; i += 4 {
+			fmt.Fprintf(fd, "| %.2x %.2x %.2x %.2x  |\t",
+				0xff&data[i], 0xff&data[i+1],
+				0xff&data[i+2], 0xff&data[i+3])
+
+			fmt.Fprintf(fd, "|      data      |")
 
-// debugf prints debugging information at the specified level, if d.Level is
-// high enough to print the message.
-func (d *debugger) debugf(level int, format string, v ...interface{}) {
-	if d.Level >= level {
-		d.Log.Printf(format, v...)
+			fmt.Fprintf(fd, "\t %s %s %s %s\n",
+				ternary(strconv.IsPrint(rune(data[i])), string(data[i]), " "),
+				ternary(strconv.IsPrint(rune(data[i+1])), string(data[i+1]), " "),
+				ternary(strconv.IsPrint(rune(data[i+2])), string(data[i+2]), " "),
+				ternary(strconv.IsPrint(rune(data[i+3])), string(data[i+3]), " "),
+			)
+		}
 	}
+	fmt.Fprintf(fd, "----------------\t------------------\n")
 }
 
-func panicf(format string, a ...interface{}) {
-	panic(fmt.Sprintf(format, a...))
+func ternary(cond bool, iftrue string, iffalse string) string {
+	if cond {
+		return iftrue
+	} else {
+		return iffalse
+	}
 }
diff --git a/debug_linux.go b/debug_linux.go
new file mode 100644
index 0000000..2937008
--- /dev/null
+++ b/debug_linux.go
@@ -0,0 +1,252 @@
+//go:build linux
+
+package netlink
+
+import (
+	"fmt"
+	"io"
+	"log"
+	"os"
+	"strconv"
+	"strings"
+	"syscall"
+	"unsafe"
+
+	"github.com/mdlayher/netlink/nlenc"
+	"golang.org/x/sys/unix"
+)
+
+// newDebugger creates a debugger by parsing key=value arguments.
+func newDebugger(args []string) *debugger {
+	d := &debugger{
+		Log:    log.New(os.Stderr, "nl: ", 0),
+		Level:  1,
+		Format: "mnl",
+	}
+
+	for _, a := range args {
+		kv := strings.Split(a, "=")
+		if len(kv) != 2 {
+			// Ignore malformed pairs and assume callers wants defaults.
+			continue
+		}
+
+		switch kv[0] {
+		// Select the log level for the debugger.
+		case "level":
+			level, err := strconv.Atoi(kv[1])
+			if err != nil {
+				panicf("netlink: invalid NLDEBUG level: %q", a)
+			}
+
+			d.Level = level
+		case "format":
+			d.Format = kv[1]
+		}
+	}
+
+	return d
+}
+
+// debugf prints debugging information at the specified level, if d.Level is
+// high enough to print the message.
+func (d *debugger) debugf(level int, format string, v ...interface{}) {
+	if d.Level < level {
+		return
+	}
+
+	switch d.Format {
+	case "mnl":
+		colorize := true
+		_, err := unix.IoctlGetWinsize(int(os.Stdout.Fd()), unix.TIOCGWINSZ)
+		if err != nil {
+			colorize = false
+		}
+
+		for _, iface := range v {
+			if msg, ok := iface.(Message); ok {
+				nlmsgFprintf(d.Log.Writer(), msg, colorize)
+			} else {
+				d.Log.Printf(format, v...)
+			}
+		}
+	default:
+		d.Log.Printf(format, v...)
+	}
+}
+
+/*
+	nlmsgFprintf - print netlink message to file
+	- Based on https://git.netfilter.org/libmnl/tree/src/nlmsg.c
+	- This function prints the netlink header to a file handle.
+	- It may be useful for debugging purposes. One example of the output
+	- is the following:
+
+----------------        ------------------
+|  0000000040  |        | message length |
+| 00016 | R-A- |        |  type | flags  |
+|  1289148991  |        | sequence number|
+|  0000000000  |        |     port ID    |
+----------------        ------------------
+| 00 00 00 00  |        |  extra header  |
+| 00 00 00 00  |        |  extra header  |
+| 01 00 00 00  |        |  extra header  |
+| 01 00 00 00  |        |  extra header  |
+|00008|--|00003|        |len |flags| type|
+| 65 74 68 30  |        |      data      |       e t h 0
+----------------        ------------------
+
+	*
+	* This example above shows the netlink message that is send to kernel-space
+	* to set up the link interface eth0. The netlink and attribute header data
+	* are displayed in base 10 whereas the extra header and the attribute payload
+	* are expressed in base 16. The possible flags in the netlink header are:
+	*
+	* - R, that indicates that NLM_F_REQUEST is set.
+	* - M, that indicates that NLM_F_MULTI is set.
+	* - A, that indicates that NLM_F_ACK is set.
+	* - E, that indicates that NLM_F_ECHO is set.
+	*
+	* The lack of one flag is displayed with '-'. On the other hand, the possible
+	* attribute flags available are:
+	*
+	* - N, that indicates that NLA_F_NESTED is set.
+	* - B, that indicates that NLA_F_NET_BYTEORDER is set.
+*/
+func nlmsgFprintfHeader(fd io.Writer, nlh Header) {
+	fmt.Fprintf(fd, "----------------\t------------------\n")
+	fmt.Fprintf(fd, "|  %010d  |\t| message length |\n", nlh.Length)
+	fmt.Fprintf(fd, "| %05d | %s%s%s%s |\t|  type | flags  |\n",
+		nlh.Type,
+		ternary(nlh.Flags&Request != 0, "R", "-"),
+		ternary(nlh.Flags&Multi != 0, "M", "-"),
+		ternary(nlh.Flags&Acknowledge != 0, "A", "-"),
+		ternary(nlh.Flags&Echo != 0, "E", "-"),
+	)
+	fmt.Fprintf(fd, "|  %010d  |\t| sequence number|\n", nlh.Sequence)
+	fmt.Fprintf(fd, "|  %010d  |\t|     port ID    |\n", nlh.PID)
+	fmt.Fprintf(fd, "----------------\t------------------\n")
+}
+
+// nlmsgFprintf checks a single Message for netlink errors.
+func nlmsgFprintf(fd io.Writer, m Message, colorize bool) {
+	var hasHeader bool
+	nlmsgFprintfHeader(fd, m.Header)
+	switch {
+	case m.Header.Type == Error:
+		hasHeader = true
+	case m.Header.Type == Done && m.Header.Flags&Multi != 0:
+		if len(m.Data) == 0 {
+			return
+		}
+	default:
+		// Neither, nothing to do.
+	}
+
+	// Errno occupies 4 bytes.
+	const endErrno = 4
+	if len(m.Data) < endErrno {
+		return
+	}
+
+	c := nlenc.Int32(m.Data[:endErrno])
+	if c != 0 {
+		b := m.Data[0:4]
+		fmt.Fprintf(fd, "| %.2x %.2x %.2x %.2x  |\t",
+			0xff&b[0], 0xff&b[1],
+			0xff&b[2], 0xff&b[3])
+		fmt.Fprintf(fd, "|  extra header  |\n")
+	}
+
+	// Flags indicate an extended acknowledgement. The type/flags combination
+	// checked above determines the offset where the TLVs occur.
+	var off int
+	if hasHeader {
+		// There is an nlmsghdr preceding the TLVs.
+		if len(m.Data) < endErrno+nlmsgHeaderLen {
+			return
+		}
+
+		// The TLVs should be at the offset indicated by the nlmsghdr.length,
+		// plus the offset where the header began. But make sure the calculated
+		// offset is still in-bounds.
+		h := *(*Header)(unsafe.Pointer(&m.Data[endErrno : endErrno+nlmsgHeaderLen][0]))
+		off = endErrno + int(h.Length)
+
+		if len(m.Data) < off {
+			return
+		}
+	} else {
+		// There is no nlmsghdr preceding the TLVs, parse them directly.
+		off = endErrno
+	}
+
+	data := m.Data[off:]
+	for i := 0; i < len(data); {
+		// Make sure there's at least a header's worth
+		// of data to read on each iteration.
+		if len(data[i:]) < nlaHeaderLen {
+			break
+		}
+
+		// Extract the length of the attribute.
+		l := int(nlenc.Uint16(data[i : i+2]))
+		// extract the type
+		t := nlenc.Uint16(data[i+2 : i+4])
+		// print attribute header
+		if colorize {
+			fmt.Fprintf(fd, "|\033[1;31m%05d|\033[1;32m%s%s|\033[1;34m%05d\033[0m|\t",
+				l,
+				ternary(t&syscall.NLA_F_NESTED != 0, "N", "-"),
+				ternary(t&syscall.NLA_F_NET_BYTEORDER != 0, "B", "-"),
+				t&attrTypeMask)
+			fmt.Fprintf(fd, "|len |flags| type|\n")
+		} else {
+			fmt.Fprintf(fd, "|%05d|%s%s|%05d|\t",
+				l,
+				ternary(t&syscall.NLA_F_NESTED != 0, "N", "-"),
+				ternary(t&syscall.NLA_F_NET_BYTEORDER != 0, "B", "-"),
+				t&attrTypeMask)
+			fmt.Fprintf(fd, "|len |flags| type|\n")
+		}
+
+		nextAttr := i + nlaAlign(l)
+
+		// advance the pointer to the bytes after the header
+		i += nlaHeaderLen
+
+		// Ignore zero-length attributes.
+		if l == 0 {
+			continue
+		}
+		// If nested check the next attribute
+		if t&syscall.NLA_F_NESTED != 0 {
+			continue
+		}
+
+		// Print the remaining attributes bytes
+		for ; i < nextAttr; i += 4 {
+			fmt.Fprintf(fd, "| %.2x %.2x %.2x %.2x  |\t",
+				0xff&data[i], 0xff&data[i+1],
+				0xff&data[i+2], 0xff&data[i+3])
+
+			fmt.Fprintf(fd, "|      data      |")
+
+			fmt.Fprintf(fd, "\t %s %s %s %s\n",
+				ternary(strconv.IsPrint(rune(data[i])), string(data[i]), " "),
+				ternary(strconv.IsPrint(rune(data[i+1])), string(data[i+1]), " "),
+				ternary(strconv.IsPrint(rune(data[i+2])), string(data[i+2]), " "),
+				ternary(strconv.IsPrint(rune(data[i+3])), string(data[i+3]), " "),
+			)
+		}
+	}
+	fmt.Fprintf(fd, "----------------\t------------------\n")
+}
+
+func ternary(cond bool, iftrue string, iffalse string) string {
+	if cond {
+		return iftrue
+	} else {
+		return iffalse
+	}
+}
diff --git a/debug_linux_test.go b/debug_linux_test.go
new file mode 100644
index 0000000..cfb55d5
--- /dev/null
+++ b/debug_linux_test.go
@@ -0,0 +1,285 @@
+//go:build linux
+// +build linux
+
+package netlink
+
+import (
+	"bytes"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"golang.org/x/sys/unix"
+)
+
+func TestNlmsgFprintf(t *testing.T) {
+	// netlink messages obtained from https://github.com/google/nftables/blob/e99829fb4f26d75fdd0cfce8ba4632744e72c2bc/nftables_test.go#L245C1-L246C94
+	tests := []struct {
+		name     string
+		m        Message
+		colorize bool
+		want     string
+	}{
+		{
+			name: "nft add table ip nat",
+			m: Message{
+				Header: Header{
+					Length:   40,
+					Type:     HeaderType(uint16(unix.NFNL_SUBSYS_NFTABLES)<<8 | uint16(unix.NFT_MSG_NEWTABLE)),
+					Flags:    Request,
+					Sequence: 1,
+					PID:      1234,
+				},
+				Data: []byte("\x02\x00\x00\x00\x08\x00\x01\x00\x6e\x61\x74\x00\x08\x00\x02\x00\x00\x00\x00\x00"),
+			},
+			colorize: false,
+			want: `----------------	------------------
+|  0000000040  |	| message length |
+| 02560 | R--- |	|  type | flags  |
+|  0000000001  |	| sequence number|
+|  0000001234  |	|     port ID    |
+----------------	------------------
+| 02 00 00 00  |	|  extra header  |
+|00008|--|00001|	|len |flags| type|
+| 6e 61 74 00  |	|      data      |	 n a t  
+|00008|--|00002|	|len |flags| type|
+| 00 00 00 00  |	|      data      |	        
+----------------	------------------
+`,
+		},
+		{
+			name: "nft add rule nat prerouting iifname uplink0 udp dport 4070-4090 dnat 192.168.23.2:4070-4090",
+			m: Message{
+				Header: Header{
+					Length:   40,
+					Type:     HeaderType(uint16(unix.NFNL_SUBSYS_NFTABLES)<<8 | uint16(unix.NFT_MSG_NEWRULE)),
+					Flags:    Request,
+					Sequence: 1,
+					PID:      1234,
+				},
+				Data: []byte("\x02\x00\x00\x00\x08\x00\x01\x00\x6e\x61\x74\x00\x0f\x00\x02\x00\x70\x72\x65\x72\x6f\x75\x74\x69\x6e\x67\x00\x00\xf8\x01\x04\x80\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x06\x08\x00\x01\x00\x00\x00\x00\x01\x38\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x2c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x18\x00\x03\x80\x14\x00\x01\x00\x75\x70\x6c\x69\x6e\x6b\x30\x00\x00\x00\x00\x00\x00\x00\x00\x00\x24\x00\x01\x80\x09\x00\x01\x00\x6d\x65\x74\x61\x00\x00\x00\x00\x14\x00\x02\x80\x08\x00\x02\x00\x00\x00\x00\x10\x08\x00\x01\x00\x00\x00\x00\x01\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x00\x0c\x00\x03\x80\x05\x00\x01\x00\x11\x00\x00\x00\x34\x00\x01\x80\x0c\x00\x01\x00\x70\x61\x79\x6c\x6f\x61\x64\x00\x24\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x02\x08\x00\x03\x00\x00\x00\x00\x02\x08\x00\x04\x00\x00\x00\x00\x02\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x05\x0c\x00\x03\x80\x06\x00\x01\x00\x0f\xe6\x00\x00\x2c\x00\x01\x80\x08\x00\x01\x00\x63\x6d\x70\x00\x20\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x03\x0c\x00\x03\x80\x06\x00\x01\x00\x0f\xfa\x00\x00\x2c\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x18\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x0c\x00\x02\x80\x08\x00\x01\x00\xc0\xa8\x17\x02\x2c\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x18\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x02\x0c\x00\x02\x80\x06\x00\x01\x00\x0f\xe6\x00\x00\x2c\x00\x01\x80\x0e\x00\x01\x00\x69\x6d\x6d\x65\x64\x69\x61\x74\x65\x00\x00\x00\x18\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x03\x0c\x00\x02\x80\x06\x00\x01\x00\x0f\xfa\x00\x00\x38\x00\x01\x80\x08\x00\x01\x00\x6e\x61\x74\x00\x2c\x00\x02\x80\x08\x00\x01\x00\x00\x00\x00\x01\x08\x00\x02\x00\x00\x00\x00\x02\x08\x00\x03\x00\x00\x00\x00\x01\x08\x00\x05\x00\x00\x00\x00\x02\x08\x00\x06\x00\x00\x00\x00\x03"),
+			},
+			colorize: false,
+			want: `----------------	------------------
+|  0000000040  |	| message length |
+| 02566 | R--- |	|  type | flags  |
+|  0000000001  |	| sequence number|
+|  0000001234  |	|     port ID    |
+----------------	------------------
+| 02 00 00 00  |	|  extra header  |
+|00008|--|00001|	|len |flags| type|
+| 6e 61 74 00  |	|      data      |	 n a t  
+|00015|--|00002|	|len |flags| type|
+| 70 72 65 72  |	|      data      |	 p r e r
+| 6f 75 74 69  |	|      data      |	 o u t i
+| 6e 67 00 00  |	|      data      |	 n g    
+|00504|N-|00004|	|len |flags| type|
+|00036|N-|00001|	|len |flags| type|
+|00009|--|00001|	|len |flags| type|
+| 6d 65 74 61  |	|      data      |	 m e t a
+| 00 00 00 00  |	|      data      |	        
+|00020|N-|00002|	|len |flags| type|
+|00008|--|00002|	|len |flags| type|
+| 00 00 00 06  |	|      data      |	        
+|00008|--|00001|	|len |flags| type|
+| 00 00 00 01  |	|      data      |	        
+|00056|N-|00001|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| 63 6d 70 00  |	|      data      |	 c m p  
+|00044|N-|00002|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| 00 00 00 01  |	|      data      |	        
+|00008|--|00002|	|len |flags| type|
+| 00 00 00 00  |	|      data      |	        
+|00024|N-|00003|	|len |flags| type|
+|00020|--|00001|	|len |flags| type|
+| 75 70 6c 69  |	|      data      |	 u p l i
+| 6e 6b 30 00  |	|      data      |	 n k 0  
+| 00 00 00 00  |	|      data      |	        
+| 00 00 00 00  |	|      data      |	        
+|00036|N-|00001|	|len |flags| type|
+|00009|--|00001|	|len |flags| type|
+| 6d 65 74 61  |	|      data      |	 m e t a
+| 00 00 00 00  |	|      data      |	        
+|00020|N-|00002|	|len |flags| type|
+|00008|--|00002|	|len |flags| type|
+| 00 00 00 10  |	|      data      |	        
+|00008|--|00001|	|len |flags| type|
+| 00 00 00 01  |	|      data      |	        
+|00044|N-|00001|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| 63 6d 70 00  |	|      data      |	 c m p  
+|00032|N-|00002|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| 00 00 00 01  |	|      data      |	        
+|00008|--|00002|	|len |flags| type|
+| 00 00 00 00  |	|      data      |	        
+|00012|N-|00003|	|len |flags| type|
+|00005|--|00001|	|len |flags| type|
+| 11 00 00 00  |	|      data      |	        
+|00052|N-|00001|	|len |flags| type|
+|00012|--|00001|	|len |flags| type|
+| 70 61 79 6c  |	|      data      |	 p a y l
+| 6f 61 64 00  |	|      data      |	 o a d  
+|00036|N-|00002|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| 00 00 00 01  |	|      data      |	        
+|00008|--|00002|	|len |flags| type|
+| 00 00 00 02  |	|      data      |	        
+|00008|--|00003|	|len |flags| type|
+| 00 00 00 02  |	|      data      |	        
+|00008|--|00004|	|len |flags| type|
+| 00 00 00 02  |	|      data      |	        
+|00044|N-|00001|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| 63 6d 70 00  |	|      data      |	 c m p  
+|00032|N-|00002|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| 00 00 00 01  |	|      data      |	        
+|00008|--|00002|	|len |flags| type|
+| 00 00 00 05  |	|      data      |	        
+|00012|N-|00003|	|len |flags| type|
+|00006|--|00001|	|len |flags| type|
+| 0f e6 00 00  |	|      data      |	   æ    
+|00044|N-|00001|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| 63 6d 70 00  |	|      data      |	 c m p  
+|00032|N-|00002|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| 00 00 00 01  |	|      data      |	        
+|00008|--|00002|	|len |flags| type|
+| 00 00 00 03  |	|      data      |	        
+|00012|N-|00003|	|len |flags| type|
+|00006|--|00001|	|len |flags| type|
+| 0f fa 00 00  |	|      data      |	   ú    
+|00044|N-|00001|	|len |flags| type|
+|00014|--|00001|	|len |flags| type|
+| 69 6d 6d 65  |	|      data      |	 i m m e
+| 64 69 61 74  |	|      data      |	 d i a t
+| 65 00 00 00  |	|      data      |	 e      
+|00024|N-|00002|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| 00 00 00 01  |	|      data      |	        
+|00012|N-|00002|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| c0 a8 17 02  |	|      data      |	 À ¨    
+|00044|N-|00001|	|len |flags| type|
+|00014|--|00001|	|len |flags| type|
+| 69 6d 6d 65  |	|      data      |	 i m m e
+| 64 69 61 74  |	|      data      |	 d i a t
+| 65 00 00 00  |	|      data      |	 e      
+|00024|N-|00002|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| 00 00 00 02  |	|      data      |	        
+|00012|N-|00002|	|len |flags| type|
+|00006|--|00001|	|len |flags| type|
+| 0f e6 00 00  |	|      data      |	   æ    
+|00044|N-|00001|	|len |flags| type|
+|00014|--|00001|	|len |flags| type|
+| 69 6d 6d 65  |	|      data      |	 i m m e
+| 64 69 61 74  |	|      data      |	 d i a t
+| 65 00 00 00  |	|      data      |	 e      
+|00024|N-|00002|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| 00 00 00 03  |	|      data      |	        
+|00012|N-|00002|	|len |flags| type|
+|00006|--|00001|	|len |flags| type|
+| 0f fa 00 00  |	|      data      |	   ú    
+|00056|N-|00001|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| 6e 61 74 00  |	|      data      |	 n a t  
+|00044|N-|00002|	|len |flags| type|
+|00008|--|00001|	|len |flags| type|
+| 00 00 00 01  |	|      data      |	        
+|00008|--|00002|	|len |flags| type|
+| 00 00 00 02  |	|      data      |	        
+|00008|--|00003|	|len |flags| type|
+| 00 00 00 01  |	|      data      |	        
+|00008|--|00005|	|len |flags| type|
+| 00 00 00 02  |	|      data      |	        
+|00008|--|00006|	|len |flags| type|
+| 00 00 00 03  |	|      data      |	        
+----------------	------------------
+`,
+		},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			w := &bytes.Buffer{}
+			nlmsgFprintf(w, tt.m, tt.colorize)
+			got := w.String()
+			if got != tt.want {
+				t.Errorf("nlmsgFprintf() =\n%s,\nwant\n%s\ndiff:\n%s", got, tt.want, cmp.Diff(got, tt.want))
+			}
+		})
+	}
+}
+
+func TestNlmsgFprintfHeader(t *testing.T) {
+	tests := []struct {
+		name string
+		h    Header
+		want string
+	}{
+		{
+			name: "Basic test",
+			h: Header{
+				Length:   16 + 4,
+				Type:     0,
+				Flags:    Request,
+				Sequence: 1,
+				PID:      123,
+			},
+			want: `----------------	------------------
+|  0000000020  |	| message length |
+| 00000 | R--- |	|  type | flags  |
+|  0000000001  |	| sequence number|
+|  0000000123  |	|     port ID    |
+----------------	------------------
+`,
+		},
+		{
+			name: "All flags",
+			h: Header{
+				Length:   16,
+				Type:     unix.NFNL_SUBSYS_IPSET << 8,
+				Flags:    Request | Multi | Acknowledge | Echo | Dump | DumpFiltered | Create | Excl | Append,
+				Sequence: 123,
+				PID:      456,
+			},
+			want: `----------------	------------------
+|  0000000016  |	| message length |
+| 01536 | RMAE |	|  type | flags  |
+|  0000000123  |	| sequence number|
+|  0000000456  |	|     port ID    |
+----------------	------------------
+`,
+		},
+		{
+			name: "Unknown type",
+			h: Header{
+				Length:   16,
+				Type:     0xffff,
+				Flags:    Request | Acknowledge,
+				Sequence: 123,
+				PID:      456,
+			},
+			want: `----------------	------------------
+|  0000000016  |	| message length |
+| 65535 | R-A- |	|  type | flags  |
+|  0000000123  |	| sequence number|
+|  0000000456  |	|     port ID    |
+----------------	------------------
+`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			w := &bytes.Buffer{}
+			nlmsgFprintfHeader(w, tt.h)
+			if got := w.String(); got != tt.want {
+				t.Errorf("nlmsgFprintfHeader() =\n%s,\nwant\n%s\ndiff:\n%s", got, tt.want, cmp.Diff(got, tt.want))
+			}
+		})
+	}
+}
diff --git a/debug_others.go b/debug_others.go
new file mode 100644
index 0000000..8880498
--- /dev/null
+++ b/debug_others.go
@@ -0,0 +1,45 @@
+//go:build !linux
+
+package netlink
+
+import (
+	"log"
+	"os"
+	"strconv"
+	"strings"
+)
+
+// newDebugger creates a debugger by parsing key=value arguments.
+func newDebugger(args []string) *debugger {
+	d := &debugger{
+		Log:   log.New(os.Stderr, "nl: ", 0),
+		Level: 1,
+	}
+	for _, a := range args {
+		kv := strings.Split(a, "=")
+		if len(kv) != 2 {
+			// Ignore malformed pairs and assume callers wants defaults.
+			continue
+		}
+		switch kv[0] {
+		// Select the log level for the debugger.
+		case "level":
+			level, err := strconv.Atoi(kv[1])
+			if err != nil {
+				panicf("netlink: invalid NLDEBUG level: %q", a)
+			}
+
+			d.Level = level
+		}
+	}
+
+	return d
+}
+
+// debugf prints debugging information at the specified level, if d.Level is
+// high enough to print the message.
+func (d *debugger) debugf(level int, format string, v ...interface{}) {
+	if d.Level >= level {
+		d.Log.Printf(format, v...)
+	}
+}
diff --git a/doc.go b/doc.go
index 98c744a..f0c51c7 100644
--- a/doc.go
+++ b/doc.go
@@ -27,7 +27,10 @@
 //
 //	$ NLDEBUG=level=1 ./nlctl
 //
+//	$ NLDEBUG=level=1,format=mnl ./nlctl
+//
 // Available key/value debugger options include:
 //
 //	level=N: specify the debugging level (only "1" is currently supported)
+//	format=mnl: specify the same format used by libmnl (nft --debug=all)
 package netlink