Understanding postgres txid_current and pg_locks
I found an answer.

There were two parts to this.

Firstly, what is important is virtualtransaction rather than virtualxid. Even though virtualxid and transactionid are null for everything I care about, they all share a virtualtransaction id.

Secondly, although txid_current() returns a transactionid, which seems like it wouldn't help because my locks all have null for that, we can still use it to get what we want.

What happens is when you call txid_current() postgres assigns a lock with the transactionid returned by that function, which we can see if we query pg_locks. This lock will share a virtualtransaction id with everything else in the current transaction.

For example:

&gt; BEGIN;
BEGIN
&gt; SELECT locktype,transactionid,virtualtransaction,mode FROM pg_locks;
  locktype  | transactionid | virtualtransaction |      mode
------------+---------------+--------------------+-----------------
 relation   |        [NULL] | 3/38672            | AccessShareLock
 virtualxid |        [NULL] | 3/38672            | ExclusiveLock
(2 rows)

&gt; SELECT txid_current();
 txid_current
--------------
       355056
(1 row)

&gt; SELECT locktype,transactionid,virtualtransaction,mode FROM pg_locks;
   locktype    | transactionid | virtualtransaction |      mode
---------------+---------------+--------------------+-----------------
 relation      |        [NULL] | 3/38672            | AccessShareLock
 virtualxid    |        [NULL] | 3/38672            | ExclusiveLock
 transactionid |        355056 | 3/38672            | ExclusiveLock
(3 rows)


So now from this we can get the virtualtransaction id shared by everything in the current transaction, and filter on that.

In other words:

SELECT
    *
FROM pg_locks pl
WHERE
    virtualtransaction=(
        SELECT virtualtransaction FROM pg_locks
            WHERE
                transactionid::text = (txid_current() % (2^32)::bigint)::text
                -- compare int to xid, see https://dba.stackexchange.com/a/123183/10371 
                AND locktype='transactionid'
        LIMIT 1
    )


...will return only locks relevant to the current transaction.