diff --git a/lib/gat/configs/pgbouncer/config.go b/lib/gat/configs/pgbouncer/config.go
new file mode 100644
index 0000000000000000000000000000000000000000..a2b5f91fa381a88d1c12e19d7617bde23e0f750e
--- /dev/null
+++ b/lib/gat/configs/pgbouncer/config.go
@@ -0,0 +1,10 @@
+package pgbouncer
+
+type Config struct {
+	Databases map[string]string `ini:"databases"`
+	Users     map[string]string `ini:"users"`
+}
+
+func Test() {
+
+}
diff --git a/lib/util/ini/unmarshal.go b/lib/util/ini/unmarshal.go
new file mode 100644
index 0000000000000000000000000000000000000000..aca4458e3c42039ad95e715259298f139994cc51
--- /dev/null
+++ b/lib/util/ini/unmarshal.go
@@ -0,0 +1,159 @@
+package ini
+
+import (
+	"bytes"
+	"errors"
+	"reflect"
+	"strings"
+)
+
+func get(rv reflect.Value, key string, fn func(rv reflect.Value) error) error {
+outer:
+	for {
+		switch rv.Kind() {
+		case reflect.Pointer:
+			if rv.IsNil() {
+				rv.Set(reflect.New(rv.Type().Elem()))
+			}
+			rv = rv.Elem()
+		case reflect.Struct, reflect.Map:
+			break outer
+		default:
+			return nil
+		}
+	}
+
+	switch rv.Kind() {
+	case reflect.Struct:
+		rt := rv.Type()
+		numFields := rt.NumField()
+		for i := 0; i < numFields; i++ {
+			field := rt.Field(i)
+			if !field.IsExported() {
+				continue
+			}
+			name, ok := field.Tag.Lookup("ini")
+			if !ok {
+				name = field.Name
+			}
+			if name == key {
+				return fn(rv.Field(i))
+			}
+		}
+		return nil
+	case reflect.Map:
+		rt := rv.Type()
+		rtKey := rt.Key()
+		if rtKey.Kind() != reflect.String {
+			return nil
+		}
+		if rv.IsNil() {
+			rv.Set(reflect.MakeMap(rt))
+		}
+		k := reflect.New(rtKey).Elem()
+		k.SetString(key)
+		v := reflect.New(rt.Elem()).Elem()
+		if err := fn(v); err != nil {
+			return err
+		}
+		rv.SetMapIndex(k, v)
+		return nil
+	default:
+		panic("unreachable")
+	}
+}
+
+func set(rv reflect.Value, value string) error {
+outer:
+	for {
+		switch rv.Kind() {
+		case reflect.Pointer:
+			if rv.IsNil() {
+				rv.Set(reflect.New(rv.Type().Elem()))
+			}
+			rv = rv.Elem()
+		case reflect.Struct, reflect.Map, reflect.String:
+			break outer
+		default:
+			return errors.New("cannot set value of this type")
+		}
+	}
+
+	switch rv.Kind() {
+	case reflect.Struct, reflect.Map:
+		fields := strings.Fields(value)
+		for _, field := range fields {
+			k, v, ok := strings.Cut(field, "=")
+			if !ok {
+				return errors.New("expected key=value")
+			}
+			if err := get(rv, k, func(rvValue reflect.Value) error {
+				return set(rvValue, v)
+			}); err != nil {
+				return err
+			}
+		}
+		return nil
+	case reflect.String:
+		rv.SetString(value)
+		return nil
+	default:
+		panic("unreachable")
+	}
+}
+
+func setpath(rv reflect.Value, section, key, value string) error {
+	return get(rv, section, func(sec reflect.Value) error {
+		return get(sec, key, func(entry reflect.Value) error {
+			return set(entry, value)
+		})
+	})
+}
+
+func Unmarshal(data []byte, v any) error {
+	rv := reflect.ValueOf(v)
+	if rv.Kind() != reflect.Pointer || rv.IsNil() {
+		return errors.New("expected pointer to non nil")
+	}
+	rv = rv.Elem()
+
+	var section string
+
+	var line []byte
+	for {
+		line, data, _ = bytes.Cut(data, []byte{'\n'})
+		if len(line) == 0 {
+			if len(data) == 0 {
+				break
+			}
+			continue
+		}
+
+		line = bytes.TrimSpace(line)
+
+		// comment
+		if bytes.HasPrefix(line, []byte{';'}) || bytes.HasPrefix(line, []byte{'#'}) {
+			continue
+		}
+
+		// section
+		if bytes.HasPrefix(line, []byte{'['}) && bytes.HasSuffix(line, []byte{']'}) {
+			section = string(line[1 : len(line)-1])
+			continue
+		}
+
+		// kv pair
+		key, value, ok := bytes.Cut(line, []byte{'='})
+		if !ok {
+			return errors.New("expected key = value")
+		}
+		key = bytes.TrimSpace(key)
+		value = bytes.TrimSpace(value)
+
+		if err := setpath(rv, section, string(key), string(value)); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/lib/util/ini/unmarshal_test.go b/lib/util/ini/unmarshal_test.go
new file mode 100644
index 0000000000000000000000000000000000000000..9a0af8a9ecb6d6a1ec682307278ad715264e2262
--- /dev/null
+++ b/lib/util/ini/unmarshal_test.go
@@ -0,0 +1,176 @@
+package ini
+
+import (
+	"reflect"
+	"testing"
+)
+
+type Database struct {
+	Host           string `ini:"host"`
+	Port           string `ini:"port"`
+	User           string `ini:"user"`
+	Password       string `ini:"password"`
+	ClientEncoding string `ini:"client_encoding"`
+	Datestyle      string `ini:"datestyle"`
+	DBName         string `ini:"dbname"`
+	AuthUser       string `ini:"auth_user"`
+}
+
+type Peer struct {
+	Host string `ini:"host"`
+}
+
+type PoolMode string
+
+const (
+	PoolModeSession     PoolMode = "session"
+	PoolModeTransaction PoolMode = "transaction"
+)
+
+type PgBouncer struct {
+	PoolMode      PoolMode `ini:"pool_mode"`
+	ListenPort    string   `ini:"listen_port"`
+	ListenAddr    string   `ini:"listen_addr"`
+	AuthType      string   `ini:"auth_type"`
+	AuthFile      string   `ini:"auth_file"`
+	Logfile       string   `ini:"logfile"`
+	Pidfile       string   `ini:"pidfile"`
+	AdminUsers    string   `ini:"admin_users"`
+	StatsUsers    string   `ini:"stats_users"`
+	SoReuseport   string   `ini:"so_reuseport"`
+	UnixSocketDir string   `ini:"unix_socket_dir"`
+	PeerId        string   `ini:"peer_id"`
+}
+
+type Root struct {
+	Databases map[string]Database `ini:"databases"`
+	Peers     map[string]Peer     `ini:"peers"`
+	PgBouncer PgBouncer           `ini:"pgbouncer"`
+}
+
+type Case struct {
+	Value    string
+	Expected Root
+}
+
+var Cases = []Case{
+	{
+		Value: `[databases]
+postgres = host=localhost dbname=postgres
+
+[peers]
+1 = host=/tmp/pgbouncer1
+2 = host=/tmp/pgbouncer2
+
+[pgbouncer]
+listen_addr=127.0.0.1
+auth_file=auth_file.conf
+so_reuseport=1
+; only unix_socket_dir and peer_id are different
+unix_socket_dir=/tmp/pgbouncer2
+peer_id=2
+`,
+		Expected: Root{
+			Databases: map[string]Database{
+				"postgres": {
+					Host:   "localhost",
+					DBName: "postgres",
+				},
+			},
+			Peers: map[string]Peer{
+				"1": {
+					Host: "/tmp/pgbouncer1",
+				},
+				"2": {
+					Host: "/tmp/pgbouncer2",
+				},
+			},
+			PgBouncer: PgBouncer{
+				ListenAddr:    "127.0.0.1",
+				AuthFile:      "auth_file.conf",
+				SoReuseport:   "1",
+				UnixSocketDir: "/tmp/pgbouncer2",
+				PeerId:        "2",
+			},
+		},
+	},
+	{
+		Value: `[databases]
+template1 = host=localhost dbname=template1 auth_user=someuser
+
+[pgbouncer]
+pool_mode = session
+listen_port = 6432
+listen_addr = localhost
+auth_type = md5
+auth_file = users.txt
+logfile = pgbouncer.log
+pidfile = pgbouncer.pid
+admin_users = someuser
+stats_users = stat_collector`,
+		Expected: Root{
+			Databases: map[string]Database{
+				"template1": {
+					Host:     "localhost",
+					DBName:   "template1",
+					AuthUser: "someuser",
+				},
+			},
+			PgBouncer: PgBouncer{
+				PoolMode:   PoolModeSession,
+				ListenPort: "6432",
+				ListenAddr: "localhost",
+				AuthType:   "md5",
+				AuthFile:   "users.txt",
+				Logfile:    "pgbouncer.log",
+				Pidfile:    "pgbouncer.pid",
+				AdminUsers: "someuser",
+				StatsUsers: "stat_collector",
+			},
+		},
+	},
+	{
+		Value: `[databases]
+
+; foodb over Unix socket
+foodb =
+
+; redirect bardb to bazdb on localhost
+bardb = host=localhost dbname=bazdb
+
+; access to destination database will go with single user
+forcedb = host=localhost port=300 user=baz password=foo client_encoding=UNICODE datestyle=ISO`,
+		Expected: Root{
+			Databases: map[string]Database{
+				"foodb": {},
+				"bardb": {
+					Host:   "localhost",
+					DBName: "bazdb",
+				},
+				"forcedb": {
+					Host:           "localhost",
+					Port:           "300",
+					User:           "baz",
+					Password:       "foo",
+					ClientEncoding: "UNICODE",
+					Datestyle:      "ISO",
+				},
+			},
+		},
+	},
+}
+
+func TestUnmarshal(t *testing.T) {
+	for _, cas := range Cases {
+		var result Root
+		err := Unmarshal([]byte(cas.Value), &result)
+		if err != nil {
+			t.Error(err)
+			continue
+		}
+		if !reflect.DeepEqual(result, cas.Expected) {
+			t.Error("result != expected")
+			continue
+		}
+	}
+}