Note
Lazy transaction id assignment
BEGIN does not hand out a transaction id (xid). A transaction that only reads never gets one; it runs under a lightweight virtual transaction id made of the backend id and a per-backend counter. The real 32-bit xid is assigned lazily, on the first operation that needs to mark a tuple: an INSERT, UPDATE, DELETE, or a lock that has to be recorded.
Xids are scarce, and they wrap around. Every assigned xid must eventually be frozen by vacuum to avoid wraparound, so handing them to read-only transactions would burn through the space and create work for no reason.
Related: pd_lsn vs record LSN, and a post on what happens between BEGIN and COMMIT once it is written.