From 30b8f2eac8dc8f8b31556e242db90329e8f392b2 Mon Sep 17 00:00:00 2001
From: Andrey Borodin <amborodin@acm.org>
Date: Wed, 22 Apr 2026 11:14:17 +0500
Subject: [PATCH] libpq: Add DNS SRV record support for service discovery

Add a new connection parameter "srvhost" that causes libpq to query
_postgresql._tcp.<srvhost> SRV records before connecting, replacing
the normal host list with the sorted result.  This allows a whole
PostgreSQL cluster to be addressed by a single DNS name.

New connection options:
  srvhost=<domain>               DNS domain for SRV lookup (env: PGSRVHOST)
  postgresql+srv://<domain>/<db> URI shorthand for srvhost
  postgres+srv://<domain>/<db>   alias

SRV records are sorted per RFC 2782 (priority ascending, weight
descending) and injected into the existing multi-host machinery before
connhost[] is built, so target_session_attrs, load_balance_hosts, and
failover all work transparently on the expanded host list.

srvhost is mutually exclusive with host and hostaddr.  Multiple hosts
in a +srv URI are rejected because expansion is DNS's job.

On POSIX, resolution uses res_query(3) + dn_expand(3) to parse the
DNS wire format directly, avoiding ns_initparse() which is absent on
musl.  On Windows, DnsQuery() from windns.h is used instead.
Platforms without either receive a clear error at connect time.

The resolver is called from pqConnectOptions2() before the connhost[]
array is allocated, requiring no changes to PQconnectPoll or the
connection state machine.
---
 configure                             |  56 ++++
 configure.ac                          |  11 +
 doc/src/sgml/libpq.sgml               |  52 +++
 meson.build                           |  20 ++
 src/include/pg_config.h.in            |   3 +
 src/interfaces/libpq/Makefile         |  13 +-
 src/interfaces/libpq/fe-connect-srv.c | 437 ++++++++++++++++++++++++++
 src/interfaces/libpq/fe-connect-srv.h |  29 ++
 src/interfaces/libpq/fe-connect.c     |  83 ++++-
 src/interfaces/libpq/libpq-int.h      |   5 +
 src/interfaces/libpq/meson.build      |   1 +
 src/interfaces/libpq/t/007_srv.pl     | 159 ++++++++++
 12 files changed, 863 insertions(+), 6 deletions(-)
 create mode 100644 src/interfaces/libpq/fe-connect-srv.c
 create mode 100644 src/interfaces/libpq/fe-connect-srv.h
 create mode 100644 src/interfaces/libpq/t/007_srv.pl

diff --git a/configure b/configure
index f66c1054a7..175a0bfcaf 100755
--- a/configure
+++ b/configure
@@ -12615,6 +12615,62 @@ if test "$ac_res" != no; then :
 fi
 
 
+{ $as_echo "$as_me:${as_lineno-$LINENO}: checking for library containing res_query" >&5
+$as_echo_n "checking for library containing res_query... " >&6; }
+if ${ac_cv_search_res_query+:} false; then :
+  $as_echo_n "(cached) " >&6
+else
+  ac_func_search_save_LIBS=$LIBS
+cat confdefs.h - <<_ACEOF >conftest.$ac_ext
+/* end confdefs.h.  */
+
+#ifdef __cplusplus
+extern "C"
+#endif
+char res_query ();
+int
+main ()
+{
+return res_query ();
+  ;
+  return 0;
+}
+_ACEOF
+for ac_lib in '' resolv; do
+  if test -z "$ac_lib"; then
+    ac_res="none required"
+  else
+    ac_res=-l$ac_lib
+    LIBS="-l$ac_lib  $ac_func_search_save_LIBS"
+  fi
+  if ac_fn_c_try_link "$LINENO"; then :
+  ac_cv_search_res_query=$ac_res
+fi
+rm -f core conftest.err conftest.$ac_objext \
+    conftest$ac_exeext
+  if ${ac_cv_search_res_query+:} false; then :
+  break
+fi
+done
+if ${ac_cv_search_res_query+:} false; then :
+
+else
+  ac_cv_search_res_query=no
+fi
+rm conftest.$ac_ext
+LIBS=$ac_func_search_save_LIBS
+fi
+{ $as_echo "$as_me:${as_lineno-$LINENO}: result: $ac_cv_search_res_query" >&5
+$as_echo "$ac_cv_search_res_query" >&6; }
+ac_res=$ac_cv_search_res_query
+if test "$ac_res" != no; then :
+  test "$ac_res" = "none required" || LIBS="$ac_res $LIBS"
+
+$as_echo "#define HAVE_RES_QUERY 1" >>confdefs.h
+
+fi
+
+
 if test "$with_readline" = yes; then
 
 
diff --git a/configure.ac b/configure.ac
index 8d176bd346..36d836275b 100644
--- a/configure.ac
+++ b/configure.ac
@@ -1386,6 +1386,17 @@ AC_SEARCH_LIBS(backtrace_symbols, execinfo)
 
 AC_SEARCH_LIBS(pthread_barrier_wait, pthread)
 
+dnl
+dnl DNS SRV record support in libpq.
+dnl On Linux and some BSDs res_query() lives in libresolv; on macOS it is
+dnl available from the system library without any extra -l flag.
+dnl AC_SEARCH_LIBS handles both cases: it tries libc first, then libresolv.
+dnl
+AC_SEARCH_LIBS(res_query, resolv,
+  [AC_DEFINE([HAVE_RES_QUERY], 1,
+             [Define to 1 if you have the res_query() DNS resolver function.])])
+AC_CHECK_HEADERS([arpa/nameser.h resolv.h])
+
 if test "$with_readline" = yes; then
   PGAC_CHECK_READLINE
   if test x"$pgac_cv_check_readline" = x"no"; then
diff --git a/doc/src/sgml/libpq.sgml b/doc/src/sgml/libpq.sgml
index 0a19c2b553..db3e669a74 100644
--- a/doc/src/sgml/libpq.sgml
+++ b/doc/src/sgml/libpq.sgml
@@ -1129,6 +1129,48 @@ postgresql://%2Fvar%2Flib%2Fpostgresql/dbname
     The currently recognized parameter key words are:
 
     <variablelist>
+     <varlistentry id="libpq-connect-srvhost" xreflabel="srvhost">
+      <term><literal>srvhost</literal></term>
+      <listitem>
+       <para>
+        DNS domain name used for SRV-based service discovery (RFC 2782).
+        When set, libpq queries DNS for
+        <literal>_postgresql._tcp.<replaceable>srvhost</replaceable></literal>
+        SRV records and derives the list of hosts and ports to try from the
+        sorted result.  Records are ordered by priority (ascending) then
+        weight (descending), exactly as specified in RFC 2782.
+       </para>
+       <para>
+        This parameter is mutually exclusive with <literal>host</literal> and
+        <literal>hostaddr</literal>.  It works together with all other
+        connection parameters, including <literal>target_session_attrs</literal>
+        and <literal>load_balance_hosts</literal>, which are applied to the
+        expanded host list after DNS resolution.
+       </para>
+       <para>
+        The environment variable equivalent is <envar>PGSRVHOST</envar>.
+       </para>
+       <para>
+        The <literal>postgresql+srv://</literal> and
+        <literal>postgres+srv://</literal> URI schemes are a shorthand for
+        setting <literal>srvhost</literal> from the URI host part:
+
+<synopsis>
+postgresql+srv://<replaceable>cluster.example.com</replaceable>/<replaceable>mydb</replaceable>
+</synopsis>
+
+        is equivalent to:
+
+<synopsis>
+postgresql:///<replaceable>mydb</replaceable>?srvhost=<replaceable>cluster.example.com</replaceable>
+</synopsis>
+
+        Multiple hosts are not permitted in a <literal>+srv</literal> URI
+        because the host expansion is performed by DNS.
+       </para>
+      </listitem>
+     </varlistentry>
+
      <varlistentry id="libpq-connect-host" xreflabel="host">
       <term><literal>host</literal></term>
       <listitem>
@@ -9060,6 +9102,16 @@ myEventProc(PGEventId evtId, void *evtInfo, void *passThrough)
    information into simple client applications, for example.
 
    <itemizedlist>
+    <listitem>
+     <para>
+      <indexterm>
+       <primary><envar>PGSRVHOST</envar></primary>
+      </indexterm>
+      <envar>PGSRVHOST</envar> behaves the same as the <xref
+      linkend="libpq-connect-srvhost"/> connection parameter.
+     </para>
+    </listitem>
+
     <listitem>
      <para>
       <indexterm>
diff --git a/meson.build b/meson.build
index 20b887f1a1..76533e7f45 100644
--- a/meson.build
+++ b/meson.build
@@ -376,6 +376,8 @@ elif host_system == 'windows'
   secur32_dep = cc.find_library('secur32', required: true)
   backend_deps += secur32_dep
   libpq_deps += secur32_dep
+  # DnsQuery() for SRV record lookup on Windows
+  libpq_deps += cc.find_library('dnsapi', required: true)
 
   postgres_inc_d += 'src/include/port/win32'
   if cc.get_id() == 'msvc'
@@ -1851,6 +1853,24 @@ endif
 
 
 
+###############################################################
+# DNS SRV support (libpq)
+###############################################################
+
+# res_query() and dn_expand() are used by fe-connect-srv.c to resolve SRV
+# records.  On most POSIX systems they live in libresolv; on macOS they are
+# in the system library but still require explicit linking with libresolv.
+if host_machine.system() != 'windows'
+  resolv_dep = cc.find_library('resolv', required: false)
+  if resolv_dep.found()
+    cdata.set('HAVE_RES_QUERY', 1)
+    libpq_deps += resolv_dep
+  elif cc.has_function('res_query')
+    cdata.set('HAVE_RES_QUERY', 1)
+  endif
+endif
+
+
 ###############################################################
 # Compiler tests
 ###############################################################
diff --git a/src/include/pg_config.h.in b/src/include/pg_config.h.in
index 4f8113c144..40dbd52cff 100644
--- a/src/include/pg_config.h.in
+++ b/src/include/pg_config.h.in
@@ -204,6 +204,9 @@
 /* Define to 1 if you have the <ifaddrs.h> header file. */
 #undef HAVE_IFADDRS_H
 
+/* Define to 1 if you have the `res_query' DNS resolver function. */
+#undef HAVE_RES_QUERY
+
 /* Define to 1 if you have the `inet_aton' function. */
 #undef HAVE_INET_ATON
 
diff --git a/src/interfaces/libpq/Makefile b/src/interfaces/libpq/Makefile
index 0963995eed..4e20645543 100644
--- a/src/interfaces/libpq/Makefile
+++ b/src/interfaces/libpq/Makefile
@@ -34,6 +34,7 @@ OBJS = \
 	fe-auth-scram.o \
 	fe-cancel.o \
 	fe-connect.o \
+	fe-connect-srv.o \
 	fe-exec.o \
 	fe-lobj.o \
 	fe-misc.o \
@@ -88,11 +89,21 @@ endif
 SHLIB_LINK_INTERNAL = -lpgcommon_shlib -lpgport_shlib
 ifneq ($(PORTNAME), win32)
 SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi_krb5 -lgss -lgssapi -lssl -lsocket -lnsl -lresolv -lintl -ldl -lm, $(LIBS)) $(LDAP_LIBS_FE) $(PTHREAD_LIBS)
+# DNS SRV: link with libresolv if the library exists and -lresolv is not
+# already in SHLIB_LINK (e.g. because configure added it to LIBS).
+# On Linux/glibc res_query lives in libresolv; on macOS it is in
+# /usr/lib/libresolv.dylib.  The empty-source link test is portable.
+ifeq ($(filter -lresolv, $(SHLIB_LINK)),)
+HAVE_RESOLV_LIB := $(shell echo 'int main(void){return 0;}' | $(CC) -lresolv -o /dev/null -x c - 2>/dev/null && echo yes)
+ifeq ($(HAVE_RESOLV_LIB), yes)
+SHLIB_LINK += -lresolv
+endif
+endif
 else
 SHLIB_LINK += $(filter -lcrypt -ldes -lcom_err -lcrypto -lk5crypto -lkrb5 -lgssapi32 -lssl -lsocket -lnsl -lresolv -lintl -lm $(PTHREAD_LIBS), $(LIBS)) $(LDAP_LIBS_FE)
 endif
 ifeq ($(PORTNAME), win32)
-SHLIB_LINK += -lshell32 -lws2_32 -lsecur32 $(filter -lcomerr32 -lkrb5_32, $(LIBS))
+SHLIB_LINK += -lshell32 -lws2_32 -lsecur32 -ldnsapi $(filter -lcomerr32 -lkrb5_32, $(LIBS))
 endif
 SHLIB_PREREQS = submake-libpgport
 
diff --git a/src/interfaces/libpq/fe-connect-srv.c b/src/interfaces/libpq/fe-connect-srv.c
new file mode 100644
index 0000000000..c5b7faf827
--- /dev/null
+++ b/src/interfaces/libpq/fe-connect-srv.c
@@ -0,0 +1,437 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-connect-srv.c
+ *	  DNS SRV record lookup for libpq service discovery.
+ *
+ * When a connection string specifies "srvhost=cluster.example.com" (or the
+ * equivalent URI "postgresql+srv://cluster.example.com/"), libpq resolves
+ * _postgresql._tcp.<srvhost> SRV records, sorts them by priority (ascending)
+ * then weight (descending) per RFC 2782, and expands the result into the
+ * conn->pghost and conn->pgport fields before pqConnectOptions2() builds the
+ * per-host connection array.  All existing multi-host machinery (failover,
+ * target_session_attrs, load_balance_hosts) then works unchanged.
+ *
+ * Platform support:
+ *   - POSIX systems with res_query(3): Linux (glibc), macOS, *BSD, Solaris.
+ *     Guarded by HAVE_RES_QUERY which is set by configure / meson.
+ *   - Windows: DnsQuery() from windns.h, linked with Dnsapi.lib.
+ *   - All other platforms: srvhost is rejected at connection time with an
+ *     informative error message.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-connect-srv.c
+ *
+ *-------------------------------------------------------------------------
+ */
+
+#include "postgres_fe.h"
+
+#include "common/int.h"
+#include "fe-connect-srv.h"
+#include "libpq-int.h"
+#include "pqexpbuffer.h"
+
+/* ----------
+ * Internal SRV record representation.
+ * ----------
+ */
+typedef struct
+{
+	uint16_t	priority;
+	uint16_t	weight;
+	uint16_t	port;
+	char		target[NI_MAXHOST];
+} SRVRecord;
+
+/* Sort: lower priority first; higher weight first within same priority. */
+static int
+compareSRVRecords(const void *a, const void *b)
+{
+	const SRVRecord *sa = (const SRVRecord *) a;
+	const SRVRecord *sb = (const SRVRecord *) b;
+
+	if (sa->priority != sb->priority)
+		return pg_cmp_u16(sa->priority, sb->priority);
+	return pg_cmp_u16(sb->weight, sa->weight);
+}
+
+/*
+ * Build conn->pghost and conn->pgport as comma-separated strings from a
+ * sorted SRVRecord array.  Returns true on success; on OOM sets the
+ * connection error and returns false.
+ */
+static bool
+srv_build_host_port(PGconn *conn, SRVRecord *records, int nrecords)
+{
+	PQExpBufferData hostbuf;
+	PQExpBufferData portbuf;
+
+	initPQExpBuffer(&hostbuf);
+	initPQExpBuffer(&portbuf);
+
+	for (int i = 0; i < nrecords; i++)
+	{
+		if (i > 0)
+		{
+			appendPQExpBufferChar(&hostbuf, ',');
+			appendPQExpBufferChar(&portbuf, ',');
+		}
+		appendPQExpBufferStr(&hostbuf, records[i].target);
+		appendPQExpBuffer(&portbuf, "%u", records[i].port);
+	}
+
+	if (PQExpBufferDataBroken(hostbuf) || PQExpBufferDataBroken(portbuf))
+	{
+		termPQExpBuffer(&hostbuf);
+		termPQExpBuffer(&portbuf);
+		libpq_append_conn_error(conn, "out of memory");
+		return false;
+	}
+
+	/*
+	 * Replace conn->pghost and conn->pgport with the SRV-derived values.
+	 * The originals were either NULL or empty (caller verified), but free
+	 * them defensively before overwriting.
+	 */
+	free(conn->pghost);
+	conn->pghost = strdup(hostbuf.data);
+	free(conn->pgport);
+	conn->pgport = strdup(portbuf.data);
+
+	termPQExpBuffer(&hostbuf);
+	termPQExpBuffer(&portbuf);
+
+	if (!conn->pghost || !conn->pgport)
+	{
+		libpq_append_conn_error(conn, "out of memory");
+		return false;
+	}
+
+	return true;
+}
+
+
+/* ====================================================================
+ * Platform-specific implementations
+ * ==================================================================== */
+
+#ifdef WIN32
+
+/*
+ * Windows implementation using DnsQuery() from windns.h.
+ * Link with -ldnsapi (Dnsapi.lib).
+ */
+#include <windns.h>
+
+bool
+pqLookupSRVHosts(PGconn *conn)
+{
+	char		qname[NI_MAXHOST + 30];
+	PDNS_RECORD pDnsRecord = NULL;
+	PDNS_RECORD pRec;
+	SRVRecord  *records = NULL;
+	int			nrecords = 0;
+	int			maxrecords = 0;
+	DNS_STATUS	status;
+	bool		ok;
+
+	snprintf(qname, sizeof(qname), "_postgresql._tcp.%s", conn->srvhost);
+
+	status = DnsQuery_A(qname, DNS_TYPE_SRV, DNS_QUERY_STANDARD,
+						NULL, &pDnsRecord, NULL);
+	if (status != ERROR_SUCCESS)
+	{
+		libpq_append_conn_error(conn,
+								"DNS SRV lookup failed for \"%s\": error code %lu",
+								qname, (unsigned long) status);
+		return false;
+	}
+
+	/* Collect SRV records from the linked list */
+	for (pRec = pDnsRecord; pRec != NULL; pRec = pRec->pNext)
+	{
+		SRVRecord  *tmp;
+		SRVRecord  *rec;
+		size_t		tlen;
+
+		if (pRec->wType != DNS_TYPE_SRV)
+			continue;
+
+		if (nrecords >= maxrecords)
+		{
+			maxrecords = maxrecords ? maxrecords * 2 : 4;
+			tmp = realloc(records, maxrecords * sizeof(SRVRecord));
+			if (!tmp)
+			{
+				DnsRecordListFree(pDnsRecord, DnsFreeRecordList);
+				free(records);
+				libpq_append_conn_error(conn, "out of memory");
+				return false;
+			}
+			records = tmp;
+		}
+
+		rec = &records[nrecords];
+		rec->priority = pRec->Data.SRV.wPriority;
+		rec->weight = pRec->Data.SRV.wWeight;
+		rec->port = pRec->Data.SRV.wPort;
+
+		/*
+		 * DnsQuery_A returns pNameTarget as a plain ANSI string (PSTR);
+		 * no wide-char conversion needed.
+		 */
+		strlcpy(rec->target, pRec->Data.SRV.pNameTarget, sizeof(rec->target));
+
+		/* Strip trailing dot from FQDN */
+		tlen = strlen(rec->target);
+		if (tlen > 0 && rec->target[tlen - 1] == '.')
+			rec->target[tlen - 1] = '\0';
+
+		nrecords++;
+	}
+
+	DnsRecordListFree(pDnsRecord, DnsFreeRecordList);
+
+	if (nrecords == 0)
+	{
+		libpq_append_conn_error(conn, "no SRV records found for \"%s\"", qname);
+		free(records);
+		return false;
+	}
+
+	qsort(records, nrecords, sizeof(SRVRecord), compareSRVRecords);
+
+	ok = srv_build_host_port(conn, records, nrecords);
+
+	free(records);
+	return ok;
+}
+
+#elif defined(HAVE_RES_QUERY)
+
+/*
+ * POSIX implementation using res_query() and manual DNS wire-format parsing.
+ *
+ * HAVE_RES_QUERY is set by configure (AC_SEARCH_LIBS) and meson
+ * (cc.has_function / find_library) when res_query() is available.
+ *
+ * We avoid depending on ns_initparse() / ns_parserr() since those symbols
+ * are not available on all platforms (e.g. musl libc).  Instead we parse
+ * the wire format directly, using only dn_expand() for name decompression
+ * (which is universally available wherever res_query() is).
+ */
+
+#include <arpa/nameser.h>
+#include <resolv.h>
+#include <netdb.h>
+
+/* DNS wire-format constants (from RFC 1035 / RFC 2782) */
+#ifndef T_SRV
+#define T_SRV		33		/* RFC 2782: Service locator */
+#endif
+#ifndef C_IN
+#define C_IN		1		/* the Internet */
+#endif
+#ifndef HFIXEDSZ
+#define HFIXEDSZ	12		/* DNS message header size */
+#endif
+
+/*
+ * Skip a (possibly compressed) DNS domain name starting at cp.
+ * Returns bytes consumed, or -1 on error.
+ */
+static int
+srv_skip_dname(const unsigned char *eom, const unsigned char *cp)
+{
+	const unsigned char *orig = cp;
+
+	while (cp < eom)
+	{
+		int			n = *cp & 0xFF;
+
+		if (n == 0)
+			return (int) ((cp - orig) + 1);	/* root label */
+		if ((n & 0xC0) == 0xC0)
+			return (int) ((cp - orig) + 2);	/* 2-byte pointer */
+		if ((n & 0xC0) != 0)
+			return -1;			/* unknown label type */
+		cp += n + 1;
+	}
+	return -1;					/* truncated */
+}
+
+bool
+pqLookupSRVHosts(PGconn *conn)
+{
+	char		qname[NI_MAXHOST + 30];
+	unsigned char answer[4096];
+	int			len;
+	const unsigned char *cp;
+	const unsigned char *eom;
+	int			qdcount,
+				ancount;
+	SRVRecord  *records = NULL;
+	int			nrecords = 0;
+	int			maxrecords = 0;
+	bool		retval = false;
+
+	snprintf(qname, sizeof(qname), "_postgresql._tcp.%s", conn->srvhost);
+
+	len = res_query(qname, C_IN, T_SRV, answer, sizeof(answer));
+	if (len < 0)
+	{
+		/*
+		 * h_errno is set by res_query on failure.  HOST_NOT_FOUND and
+		 * NO_DATA both indicate that the SRV RRset simply doesn't exist.
+		 */
+		const char *reason;
+
+		switch (h_errno)
+		{
+			case HOST_NOT_FOUND:
+				reason = "host not found";
+				break;
+			case NO_DATA:
+				reason = "no SRV records published";
+				break;
+			case TRY_AGAIN:
+				reason = "temporary DNS failure, try again";
+				break;
+			default:
+				reason = hstrerror(h_errno);
+				break;
+		}
+		libpq_append_conn_error(conn,
+								"DNS SRV lookup failed for \"%s\": %s",
+								qname, reason);
+		return false;
+	}
+
+	if (len < HFIXEDSZ)
+	{
+		libpq_append_conn_error(conn,
+								"DNS response too short for \"%s\"", qname);
+		return false;
+	}
+
+	eom = answer + len;
+
+	/*
+	 * Parse the DNS response header (RFC 1035 §4.1.1):
+	 *   bytes 4-5: QDCOUNT, bytes 6-7: ANCOUNT
+	 */
+	qdcount = (answer[4] << 8) | answer[5];
+	ancount = (answer[6] << 8) | answer[7];
+	cp = answer + HFIXEDSZ;
+
+	/* Skip the question section */
+	for (int i = 0; i < qdcount; i++)
+	{
+		int			n = srv_skip_dname(eom, cp);
+
+		if (n < 0)
+			goto parse_error;
+		cp += n + 4;			/* name + QTYPE + QCLASS */
+		if (cp > eom)
+			goto parse_error;
+	}
+
+	/* Parse the answer section */
+	for (int i = 0; i < ancount; i++)
+	{
+		int			n;
+		uint16_t	rtype,
+					rdlen;
+		char		target[NI_MAXHOST];
+		size_t		tlen;
+
+		n = srv_skip_dname(eom, cp);
+		if (n < 0)
+			goto parse_error;
+		cp += n;
+
+		/* Need TYPE(2) + CLASS(2) + TTL(4) + RDLENGTH(2) = 10 bytes */
+		if (cp + 10 > eom)
+			goto parse_error;
+
+		rtype = (cp[0] << 8) | cp[1];
+		rdlen = (cp[8] << 8) | cp[9];
+		cp += 10;
+
+		if (cp + rdlen > eom)
+			goto parse_error;
+
+		if (rtype == T_SRV)
+		{
+			/* SRV RDATA: priority(2) weight(2) port(2) target(variable) */
+			if (rdlen < 7)
+				goto parse_error;
+
+			n = dn_expand(answer, eom, cp + 6, target, sizeof(target));
+			if (n < 0)
+				goto parse_error;
+
+			/* Strip trailing dot from FQDN */
+			tlen = strlen(target);
+			if (tlen > 0 && target[tlen - 1] == '.')
+				target[tlen - 1] = '\0';
+
+			if (nrecords >= maxrecords)
+			{
+				SRVRecord  *tmp;
+
+				maxrecords = maxrecords ? maxrecords * 2 : 4;
+				tmp = realloc(records, maxrecords * sizeof(SRVRecord));
+				if (!tmp)
+				{
+					libpq_append_conn_error(conn, "out of memory");
+					goto cleanup;
+				}
+				records = tmp;
+			}
+
+			records[nrecords].priority = (cp[0] << 8) | cp[1];
+			records[nrecords].weight = (cp[2] << 8) | cp[3];
+			records[nrecords].port = (cp[4] << 8) | cp[5];
+			strlcpy(records[nrecords].target, target,
+					sizeof(records[nrecords].target));
+			nrecords++;
+		}
+
+		cp += rdlen;
+	}
+
+	if (nrecords == 0)
+	{
+		libpq_append_conn_error(conn,
+								"no SRV records found for \"%s\"", qname);
+		goto cleanup;
+	}
+
+	qsort(records, nrecords, sizeof(SRVRecord), compareSRVRecords);
+
+	retval = srv_build_host_port(conn, records, nrecords);
+	goto cleanup;
+
+parse_error:
+	libpq_append_conn_error(conn, "malformed DNS response for \"%s\"", qname);
+
+cleanup:
+	free(records);
+	return retval;
+}
+
+#else							/* no SRV support on this platform */
+
+bool
+pqLookupSRVHosts(PGconn *conn)
+{
+	libpq_append_conn_error(conn,
+							"\"srvhost\" is not supported on this platform "
+							"(DNS SRV lookup requires res_query)");
+	return false;
+}
+
+#endif							/* platform selection */
diff --git a/src/interfaces/libpq/fe-connect-srv.h b/src/interfaces/libpq/fe-connect-srv.h
new file mode 100644
index 0000000000..d4117ccb0d
--- /dev/null
+++ b/src/interfaces/libpq/fe-connect-srv.h
@@ -0,0 +1,29 @@
+/*-------------------------------------------------------------------------
+ *
+ * fe-connect-srv.h
+ *	  DNS SRV record lookup for libpq service discovery.
+ *
+ * Copyright (c) 2026, PostgreSQL Global Development Group
+ *
+ * IDENTIFICATION
+ *	  src/interfaces/libpq/fe-connect-srv.h
+ *
+ *-------------------------------------------------------------------------
+ */
+#ifndef FE_CONNECT_SRV_H
+#define FE_CONNECT_SRV_H
+
+#include "libpq-int.h"
+
+/*
+ * pqLookupSRVHosts
+ *
+ * Resolve _postgresql._tcp.<conn->srvhost> SRV records and store the
+ * result in conn->pghost and conn->pgport as comma-separated strings,
+ * suitable for consumption by pqConnectOptions2().
+ *
+ * Returns true on success, false on error (error message set in conn).
+ */
+extern bool pqLookupSRVHosts(PGconn *conn);
+
+#endif							/* FE_CONNECT_SRV_H */
diff --git a/src/interfaces/libpq/fe-connect.c b/src/interfaces/libpq/fe-connect.c
index 4272d386e6..1503fd6e36 100644
--- a/src/interfaces/libpq/fe-connect.c
+++ b/src/interfaces/libpq/fe-connect.c
@@ -30,6 +30,7 @@
 #include "common/string.h"
 #include "fe-auth.h"
 #include "fe-auth-oauth.h"
+#include "fe-connect-srv.h"
 #include "libpq-fe.h"
 #include "libpq-int.h"
 #include "mb/pg_wchar.h"
@@ -231,6 +232,10 @@ static const internalPQconninfoOption PQconninfoOptions[] = {
 		"Database-Name", "", 20,
 	offsetof(struct pg_conn, dbName)},
 
+	{"srvhost", "PGSRVHOST", NULL, NULL,
+		"Database-SRV-Host", "", 64,
+	offsetof(struct pg_conn, srvhost)},
+
 	{"host", "PGHOST", NULL, NULL,
 		"Database-Host", "", 40,
 	offsetof(struct pg_conn, pghost)},
@@ -454,6 +459,9 @@ static const pg_fe_sasl_mech *supported_sasl_mechs[] =
 /* The connection URI must start with either of the following designators: */
 static const char uri_designator[] = "postgresql://";
 static const char short_uri_designator[] = "postgres://";
+/* SRV URI variants: the host is treated as the SRV domain, not a direct host */
+static const char srv_uri_designator[] = "postgresql+srv://";
+static const char short_srv_uri_designator[] = "postgres+srv://";
 
 static bool connectOptions1(PGconn *conn, const char *conninfo);
 static bool init_allowed_encryption_methods(PGconn *conn);
@@ -1258,6 +1266,29 @@ pqConnectOptions2(PGconn *conn)
 {
 	int			i;
 
+	/*
+	 * If srvhost is set, validate mutual exclusivity with host/hostaddr and
+	 * then resolve _postgresql._tcp.<srvhost> SRV records, populating
+	 * conn->pghost and conn->pgport from the sorted results.  This must
+	 * happen before the host-array allocation below.
+	 */
+	if (conn->srvhost != NULL && conn->srvhost[0] != '\0')
+	{
+		if ((conn->pghost != NULL && conn->pghost[0] != '\0') ||
+			(conn->pghostaddr != NULL && conn->pghostaddr[0] != '\0'))
+		{
+			conn->status = CONNECTION_BAD;
+			libpq_append_conn_error(conn,
+									"cannot use \"srvhost\" together with \"host\" or \"hostaddr\"");
+			return false;
+		}
+		if (!pqLookupSRVHosts(conn))
+		{
+			conn->status = CONNECTION_BAD;
+			return false;
+		}
+	}
+
 	/*
 	 * Allocate memory for details about each host to which we might possibly
 	 * try to connect.  For that, count the number of elements in the hostaddr
@@ -6359,6 +6390,15 @@ parse_connection_string(const char *connstr, PQExpBuffer errorMessage,
 static int
 uri_prefix_length(const char *connstr)
 {
+	/* Check SRV URI variants first (longer prefixes before shorter) */
+	if (strncmp(connstr, srv_uri_designator,
+				sizeof(srv_uri_designator) - 1) == 0)
+		return sizeof(srv_uri_designator) - 1;
+
+	if (strncmp(connstr, short_srv_uri_designator,
+				sizeof(short_srv_uri_designator) - 1) == 0)
+		return sizeof(short_srv_uri_designator) - 1;
+
 	if (strncmp(connstr, uri_designator,
 				sizeof(uri_designator) - 1) == 0)
 		return sizeof(uri_designator) - 1;
@@ -6910,6 +6950,14 @@ conninfo_uri_parse(const char *uri, PQExpBuffer errorMessage,
  *
  * postgresql://[user[:password]@][netloc][:port][,...][/dbname][?param1=value1&...]
  *
+ * The postgresql+srv:// and postgres+srv:// URI schemes are also recognized:
+ *
+ * postgresql+srv://[user[:password]@][srvdomain][/dbname][?param1=value1&...]
+ *
+ * In the +srv form, the netloc is interpreted as the SRV domain (stored in
+ * the "srvhost" connection parameter) rather than as a direct host address.
+ * Multiple netloc specifications are not allowed in the +srv form.
+ *
  * Any of the URI parts might use percent-encoding (%xy).
  */
 static bool
@@ -6917,6 +6965,7 @@ conninfo_uri_parse_options(PQconninfoOption *options, const char *uri,
 						   PQExpBuffer errorMessage)
 {
 	int			prefix_len;
+	bool		is_srv_uri;
 	char	   *p;
 	char	   *buf = NULL;
 	char	   *start;
@@ -6944,8 +6993,10 @@ conninfo_uri_parse_options(PQconninfoOption *options, const char *uri,
 	}
 	start = buf;
 
-	/* Skip the URI prefix */
+	/* Skip the URI prefix and detect if this is a +srv URI */
 	prefix_len = uri_prefix_length(uri);
+	is_srv_uri = (prefix_len == sizeof(srv_uri_designator) - 1 ||
+				  prefix_len == sizeof(short_srv_uri_designator) - 1);
 	if (prefix_len == 0)
 	{
 		/* Should never happen */
@@ -7096,10 +7147,32 @@ conninfo_uri_parse_options(PQconninfoOption *options, const char *uri,
 	/* Save final values for host and port. */
 	if (PQExpBufferDataBroken(hostbuf) || PQExpBufferDataBroken(portbuf))
 		goto cleanup;
-	if (hostbuf.data[0] &&
-		!conninfo_storeval(options, "host", hostbuf.data,
-						   errorMessage, false, true))
-		goto cleanup;
+	if (hostbuf.data[0])
+	{
+		if (is_srv_uri)
+		{
+			/*
+			 * For postgresql+srv:// URIs the netloc is the SRV domain, not a
+			 * direct host address.  Store it in "srvhost" and reject multiple
+			 * hosts (commas) since SRV already expands to multiple targets.
+			 */
+			if (strchr(hostbuf.data, ',') != NULL)
+			{
+				libpq_append_error(errorMessage,
+								   "multiple hosts are not allowed in a postgresql+srv:// URI");
+				goto cleanup;
+			}
+			if (!conninfo_storeval(options, "srvhost", hostbuf.data,
+								   errorMessage, false, true))
+				goto cleanup;
+		}
+		else
+		{
+			if (!conninfo_storeval(options, "host", hostbuf.data,
+								   errorMessage, false, true))
+				goto cleanup;
+		}
+	}
 	if (portbuf.data[0] &&
 		!conninfo_storeval(options, "port", portbuf.data,
 						   errorMessage, false, true))
diff --git a/src/interfaces/libpq/libpq-int.h b/src/interfaces/libpq/libpq-int.h
index 23de98290c..cbfeee6288 100644
--- a/src/interfaces/libpq/libpq-int.h
+++ b/src/interfaces/libpq/libpq-int.h
@@ -371,6 +371,11 @@ typedef struct pg_conn_host
 struct pg_conn
 {
 	/* Saved values of connection options */
+	char	   *srvhost;		/* DNS SRV cluster domain for service
+								 * discovery; when set, _postgresql._tcp.<srvhost>
+								 * is resolved at connect time and the result
+								 * replaces pghost/pgport.  Mutually exclusive
+								 * with pghost and pghostaddr. */
 	char	   *pghost;			/* the machine on which the server is running,
 								 * or a path to a UNIX-domain socket, or a
 								 * comma-separated list of machines and/or
diff --git a/src/interfaces/libpq/meson.build b/src/interfaces/libpq/meson.build
index b0ae72167a..b5bf3f1faa 100644
--- a/src/interfaces/libpq/meson.build
+++ b/src/interfaces/libpq/meson.build
@@ -6,6 +6,7 @@ libpq_sources = files(
   'fe-auth.c',
   'fe-cancel.c',
   'fe-connect.c',
+  'fe-connect-srv.c',
   'fe-exec.c',
   'fe-lobj.c',
   'fe-misc.c',
diff --git a/src/interfaces/libpq/t/007_srv.pl b/src/interfaces/libpq/t/007_srv.pl
new file mode 100644
index 0000000000..79e50c9ed6
--- /dev/null
+++ b/src/interfaces/libpq/t/007_srv.pl
@@ -0,0 +1,159 @@
+# Copyright (c) 2026, PostgreSQL Global Development Group
+use strict;
+use warnings FATAL => 'all';
+
+use PostgreSQL::Test::Utils;
+use PostgreSQL::Test::Cluster;
+use Test::More;
+
+# This test exercises DNS SRV record support in libpq:
+#
+#   1. URI parsing: postgresql+srv:// and postgres+srv:// schemes store the
+#      netloc as "srvhost" rather than "host".
+#   2. Error handling: multiple hosts in a +srv URI, mixing srvhost with host.
+#   3. Live SRV lookup against a running PostgreSQL cluster.
+#
+# Live lookup is gated by PG_TEST_EXTRA=srv because it requires that valid
+# SRV records exist in DNS.  The test relies on the SRV_HOST environment
+# variable (defaulting to the value recommended in the PostgreSQL docs).
+
+
+# --------------------------------------------------------------------------
+# Part 1: URI parsing (no network required, uses libpq_uri_regress)
+# --------------------------------------------------------------------------
+
+my @uri_tests = (
+
+	# postgresql+srv:// — netloc becomes srvhost
+	[
+		q{postgresql+srv://cluster.example.com/mydb},
+		q{dbname='mydb' srvhost='cluster.example.com'},
+		q{},
+	],
+
+	# postgres+srv:// short form
+	[
+		q{postgres+srv://cluster.example.com/mydb},
+		q{dbname='mydb' srvhost='cluster.example.com'},
+		q{},
+	],
+
+	# with user, extra params
+	[
+		q{postgresql+srv://alice@cluster.example.com/mydb?target_session_attrs=read-write},
+		q{user='alice' dbname='mydb' srvhost='cluster.example.com' target_session_attrs='read-write'},
+		q{},
+	],
+
+	# multiple hosts must be rejected for +srv URIs
+	[
+		q{postgresql+srv://h1.example.com,h2.example.com/mydb},
+		q{},
+		q{multiple hosts are not allowed in a postgresql+srv:// URI},
+	],
+);
+
+foreach my $t (@uri_tests)
+{
+	my ($uri, $want_out, $want_err) = @$t;
+
+	my ($stdout, $stderr);
+	IPC::Run::run [ 'libpq_uri_regress', $uri ],
+	  '>' => \$stdout,
+	  '2>' => \$stderr;
+	chomp $stdout;
+	chomp $stderr;
+
+	# Strip the trailing connection-type annotation "(local)"/"(inet)" so
+	# that the test is not sensitive to socket-vs-TCP defaults.
+	$stdout =~ s/\s+\(\w+\)$//;
+
+	is($stdout, $want_out, "URI stdout: $uri");
+	like($stderr, qr/\Q$want_err\E/, "URI stderr: $uri")
+	  if $want_err ne '';
+	is($stderr, '', "URI no stderr: $uri")
+	  if $want_err eq '';
+}
+
+# --------------------------------------------------------------------------
+# Part 2: Live SRV lookup (optional, gated by PG_TEST_EXTRA)
+# --------------------------------------------------------------------------
+
+my $do_live = $ENV{PG_TEST_EXTRA} && $ENV{PG_TEST_EXTRA} =~ /\bsrv\b/;
+
+if (!$do_live)
+{
+	note 'Skipping live SRV test (not enabled in PG_TEST_EXTRA)';
+	done_testing();
+	exit 0;
+}
+
+# The live test starts a PostgreSQL cluster on a local port, publishes the
+# connection details via a mock hosts-file trick (as done in 004_load_balance_dns.pl),
+# and verifies that a postgresql+srv:// URI resolves and connects.
+#
+# When running in CI, the SRV records for $SRV_HOST must resolve to 127.0.0.1
+# on port $SRV_PORT.
+
+my $srv_host =
+  $ENV{SRV_HOST} // 'pg-srvtest';   # DNS name whose SRV records point here
+my $node = PostgreSQL::Test::Cluster->new('primary');
+$node->init;
+$node->start;
+
+my $port = $node->port;
+
+note "Testing SRV connection via srvhost=$srv_host (expect port $port)";
+
+# 1. keyword=value connection string
+my ($ret, $out, $err);
+$ret = $node->psql(
+	'postgres',
+	'SELECT 1',
+	stdout         => \$out,
+	stderr         => \$err,
+	extra_params   => [ '-d', "srvhost=$srv_host" ],
+	on_error_stop  => 0);
+is($ret, 0, "srvhost= keyword connects via SRV")
+  or diag("stderr: $err");
+
+# 2. URI form
+$ret = $node->psql(
+	'postgres',
+	'SELECT 1',
+	stdout        => \$out,
+	stderr        => \$err,
+	extra_params  => [ '-d', "postgresql+srv://$srv_host/postgres" ],
+	on_error_stop => 0);
+is($ret, 0, "postgresql+srv:// URI connects via SRV")
+  or diag("stderr: $err");
+
+# 3. target_session_attrs=any works with SRV
+$ret = $node->psql(
+	'postgres',
+	'SELECT 1',
+	stdout        => \$out,
+	stderr        => \$err,
+	extra_params  => [
+		'-d',
+		"postgresql+srv://$srv_host/postgres?target_session_attrs=any",
+	],
+	on_error_stop => 0);
+is($ret, 0, "postgresql+srv:// with target_session_attrs=any")
+  or diag("stderr: $err");
+
+# 4. Mixing srvhost and host is rejected
+$ret = $node->psql(
+	'postgres',
+	'SELECT 1',
+	stdout        => \$out,
+	stderr        => \$err,
+	extra_params  => [ '-d', "srvhost=$srv_host host=localhost" ],
+	on_error_stop => 0);
+isnt($ret, 0, "srvhost + host= is rejected");
+like($err, qr/cannot use "srvhost" together with "host"/,
+	 "correct error for srvhost + host conflict");
+
+$node->stop;
+
+done_testing();
-- 
2.50.1 (Apple Git-155)

