Skip to main content

bitcoin-core-rpc

A standalone JSON-RPC client against Bitcoin Core. As used by the btclib library.

PyPI version GitHub release development status license downloads supported Python versions implementation wheel

pre-commit.ci status lint workflow status test workflow status docs workflow status documentation build mutation workflow status integration-bitcoind workflow status deps-latest workflow status pypi-install workflow status deps-oldest workflow status os-macos workflow status os-ubuntu workflow status os-windows workflow status links workflow status sdist-rebuild workflow status codeql workflow status

OpenSSF Scorecard

Nothing but the standard library behind it, fully annotated and shipping py.typed. BitcoinCoreRpcClient invokes any one rpc method a node has, with positional or named parameters: one HTTP POST per call, basic authentication, the result or an exception.

pip install bitcoin-core-rpc

Talking to a node

from_chain is the local node of one of Core's chains: the loopback url, the port and the cookie file all come from Core's own defaults, so a node started with none of them overridden needs no arguments at all.

from bitcoin_core_rpc import BitcoinCoreRpcClient

client = BitcoinCoreRpcClient.from_chain("main")
print(client.call("getblockcount"))
print(client.call("getblockchaininfo")["chain"])

The credential is the cookie file bitcoind rewrites at every start, read at each call rather than held: a client built when the node was up still works an hour and a restart later. The datadir it is looked for under is Core's own for the platform running — %APPDATA%\Bitcoin on Windows, ~/Library/Application Support/Bitcoin on macOS, ~/.bitcoin elsewhere — so from_chain needs no help on any of the three. A node started with -datadir= somewhere else is what nothing can derive, and there the path is an argument:

from pathlib import Path

client = BitcoinCoreRpcClient(
    "http://127.0.0.1:8332",
    cookie_path=Path("/srv/bitcoin/.cookie"),
)

rpcuser and rpcpassword are the other way. They are arguments and never part of the url: a url with user:password@ in it is refused, because that string ends up in configuration files, tracebacks and logs.

client = BitcoinCoreRpcClient(
    "http://127.0.0.1:8332", user="rpcuser", password="rpcpassword"
)

A cookie or a datadir only authenticates the node that wrote it, not the chain it is running: -chain=test and a main cookie both exist. main is from_chain's own default, so a caller who names it and gets test back either passed the wrong name or reused a cookie carried over from elsewhere — verify_chain asks getblockchaininfo once and raises BtcRpcValueError if the two disagree, rather than trusting the port and the datadir name to have agreed with the node underneath them.

client = BitcoinCoreRpcClient.from_chain("main", verify_chain=True)

Signet is the case a name cannot settle: Core answers signet for the default signet and for every custom one alike, so the challenge is what tells two of them apart. Pass the one you mean, hex or bytes, as -signetchallenge takes it; the comparison is of the p2p magic it derives, which is what makes the same challenge in upper case the same challenge.

client = BitcoinCoreRpcClient.from_chain(
    "signet", verify_chain=True, signet_challenge="512102...ae"
)

assert_chain is that check on its own, for a client built at a url of your own — a node on another host, or behind a proxy — where there is no from_chain to ask for it.

client = BitcoinCoreRpcClient("https://node.example/", user="u", password="p")
client.assert_chain("signet", signet_challenge="512102...ae")

Calling

params is one value, shaped as JSON-RPC shapes it: a sequence for the positional form, a mapping for the named one. Core takes both, and which one a method wants is the method's business.

block_id = client.call("getblockhash", [700_000])
block = client.call("getblock", {"blockhash": block_id, "verbosity": 2})

Amounts do not travel as binary floating point in either direction: a number in the reply decodes as a Decimal, and a Decimal parameter is refused rather than rounded through float.

balance = client.for_wallet("hot").call("getbalance")  # Decimal, exact

for_wallet is the /wallet/<name> endpoint of a node with several wallets loaded, with the name percent-encoded — a wallet is a directory and may be called anything a filesystem accepts. Every wallet's client is derived from the client built for the node: calling it on a client that is already a wallet endpoint is refused rather than composing /wallet/hot/wallet/cold, which is no path Core serves.

call_raw is call with the envelope handed back instead of read: the same request, the same params validation, with the protocol marker an argument rather than always "2.0" — a string is sent verbatim, None sends no marker at all. The answer is the HTTP status and whatever the body parses to, with neither id, error nor shape interpreted — an array or a bare scalar comes back exactly as parsed rather than being refused the way call's own reply is, which is what a harness testing a server's own conformance wants in place of a result already extracted.

status, reply = client.call_raw("getblockcount", jsonrpc=None)

Batching

call_batch sends several (method, params) pairs in one HTTP POST, each built and each member's reply read the way call builds and reads its own. The result is a list aligned with the input: a member's RpcError sits at its own position rather than raising and discarding every answer beside it.

results = client.call_batch([("getblockhash", [700_000]), ("getblockcount", None)])

Only a failure of the whole exchange raises — a non-2xx status, a reply that is not an array — exactly as call raises for its own request. COMPARISON.md has where this is worth reaching for over a loop of call, and where it is not.

Attribute-style calls

RpcChannel wraps a client for a caller who wants rpc.getblockcount() over client.call("getblockcount") and has weighed the trade in COMPARISON.md: an unknown method is a request to the node rather than an AttributeError here.

from bitcoin_core_rpc import BitcoinCoreRpcClient, RpcChannel

rpc = RpcChannel(BitcoinCoreRpcClient.from_chain("main"))
print(rpc.getblockcount())
print(rpc.getblock(blockhash=block_id, verbosity=2))

Positional arguments become the sequence form of params, named ones the mapping form — never both at once, json-rpc having one shape per call. request_timeout and max_body_size, call's own keyword-only controls, reach call rather than travelling to the node as a named parameter; every other name starting with _, dunders included, is an AttributeError instead of a request, which is what keeps copy.deepcopy and an interactive shell's attribute probing from becoming one.

Keeping a connection alive

The default transport opens one connection per call and lets the node close it — COMPARISON.md has why. SessionTransport is this module's own alternative: one connection kept per node, reused across calls, passed the way any other transport is.

from bitcoin_core_rpc import BitcoinCoreRpcClient, SessionTransport

with SessionTransport() as session:
    client = BitcoinCoreRpcClient.from_chain("main", transport=session)
    client.call("getblockcount")
    client.call("getblockchaininfo")  # the same connection, reused

One instance is safe to share between threads: the whole exchange — connect or reuse, send, read — is serialized under one lock, at the cost of never running two calls through it at once. A caller wanting concurrency across several nodes keeps one instance per node instead.

Reading Core's -rest interface

-rest is Core's other interface, read-only, off by default, and — unlike JSON-RPC — authenticating nobody who reaches it. BitcoinCoreRestClient speaks it: get_bin for a .bin path, returning the body unread, and get_json for a .json one, returning what it parses to. It shares the transport and the chain vocabulary with BitcoinCoreRpcClient and nothing else — no credentials, -rest taking none.

from bitcoin_core_rpc import BitcoinCoreRestClient

rest = BitcoinCoreRestClient.from_chain("main")
info = rest.get_json("/chaininfo.json")
raw_tx = rest.get_bin(f"/tx/{tx_id}.bin")

There is no get_tx, no get_block, no get_utxos: path is built from Core's own documentation of -rest and appended after /rest unread. /getutxos is why a per-resource method is refused rather than merely undone here — it reads the UTXO set, so an output already spent and one never created answer the same way, and a wrapper turning that answer into None would read as telling more than /getutxos does.

When it goes wrong

Each exception below is a different thing to do about a failure. Every one of them is a FetchError, so one except FetchError covers the lot.

exception what happened what it carries
RpcError the node computed an error code, data
HttpError the exchange failed status
FetchError there was no answer to read —
from bitcoin_core_rpc import FetchError, HttpError, RpcError

try:
    raw = client.call("getrawtransaction", [tx_id])
except RpcError as e:
    if e.code == -5:  # no such transaction; a node without -txindex
        ...  # answers this for anything outside its wallet
except HttpError as e:
    if e.status == 503:  # the rpc work queue is full: try again later
        ...  # a 401 never works again, so tell the two apart
except FetchError:
    ...  # refused connection, expired timeout, no answer

There is no retry, and it is deliberate: call carries any method, so this client cannot know whether re-sending one is safe, and a timeout is not a deadline — a node that stopped answering may still be executing the call. HttpError.status is what makes a caller's own policy three lines rather than a match on the text of a message.

What it does not do

  • notifications, a request sent with no id, which a node does not answer.
  • retries, per above.
  • redirects. A 30x arrives as an HttpError rather than as a second request: the first one already carries the Authorization for the host it names, and following the redirect would send that credential wherever the Location points.
  • proxies from the environment. HTTP_PROXY is set for a browser or a package manager and inherited by everything in the shell, which is the wrong source for the decision of where a wallet command is sent. A caller who does want one passes a transport.

Migrating from AuthServiceProxy

It is not python-bitcoinrpc's AuthServiceProxy and not a port of it. That class, and the copy of it Core's test framework maintains, carry the LGPL-2.1 of their python-jsonrpc ancestry, where this is MIT: this is an implementation of the protocol and shares no line with either.

# AuthServiceProxy
rpc = AuthServiceProxy(f"http://{user}:{password}@127.0.0.1:8332")

# this client
client = BitcoinCoreRpcClient("http://127.0.0.1:8332", user=user, password=password)

docs/source/migrating.md has the rest, an AuthServiceProxy line at a time with the line that replaces it, for a caller carrying either Core's copy or python-bitcoinrpc's own. COMPARISON.md has the case for the switch beyond any one rewrite, and why the features this client does not have — and the one, dynamic dispatch, offered differently instead — are decisions rather than omissions.

Testing code that calls a node

transport is the seam, and it is public for this: a callable taking the request and a timeout, answering with the HTTP status and the body. The suite of this project opens no socket, and neither has yours to.

import json


def transport(request, timeout):
    request_id = json.loads(request.data)["id"]
    body = {"jsonrpc": "2.0", "id": request_id, "result": 481824}
    return 200, json.dumps(body).encode()


client = BitcoinCoreRpcClient(
    "http://127.0.0.1:8332", user="u", password="p", transport=transport
)
assert client.call("getblockcount") == 481824

What a transport of your own owes, none of which this module can check for it: its own bound on what it holds in memory while reading, its own bound on how long it holds the call — most client libraries spend timeout per socket operation, which a peer dripping a body resets forever — no redirect followed, and its own thread safety.

Type checking

The source is annotated throughout, mypy --strict runs over it here, and the distribution ships py.typed — so your own checker reads those annotations with no configuration of any kind:

$ mypy --strict your_code.py
your_code.py:4: error: Argument 1 to "call" of "BitcoinCoreRpcClient" has
incompatible type "int"; expected "str"

That marker is why py.typed sits inside src/bitcoin_core_rpc/: PEP 561 puts it inside a package directory and nowhere else. pyproject.toml records what the alternatives were measured to do.

Security

Basic authentication is cleartext over plain HTTP, that being what Core's rpc speaks. On loopback that cleartext is between one process and the node beside it; for a node anywhere else it is on the wire, and rpc credentials authorise every wallet command that node has. SECURITY.md carries the rest, and how to report a vulnerability.

Contributing

CONTRIBUTING.md has the commands each CI job runs, verbatim. uv sync creates the environment; uv is the only tool that has to be installed. REVIEWING.md is what a pull request is answered against.


The btclib organization and its projects are actively supported by DGI and CheckSig.

Release files for bitcoin-core-rpc 2026.9.24

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for bitcoin-core-rpc 2026.9.24
File Size Uploaded
bitcoin_core_rpc-2026.9.24.tar.gz 576.8 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for bitcoin-core-rpc 2026.9.24
File Interpreter ABI Platform
bitcoin_core_rpc-2026.9.24-py3-none-any.whl Python 3 none any Details

Total release size: 641.8 kB

Release files / bitcoin_core_rpc-2026.9.24.tar.gz

Download URL bitcoin_core_rpc-2026.9.24.tar.gz
Size 576.8 kB
Tags Source
SHA-256 checksum
How to use checksums
3e4f7f01a892753f150d07fccfc100b04567bf75ba4e3435e829bee42ddecb7a
BLAKE2b-256 checksum
How to use checksums
669fb925bfa8b1b3b57185f6d6bb4b6bf0b7fef0412145ebe86b4d385903dae6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release files / bitcoin_core_rpc-2026.9.24-py3-none-any.whl

Download URL bitcoin_core_rpc-2026.9.24-py3-none-any.whl
Size 65.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
8aebfffedeb60d6e34c6b07b5f31a9174ea5b8552d16e0d0166bf9422a0fd99a
BLAKE2b-256 checksum
How to use checksums
39ab7643e929c676ec2c0097423557d789bd9a563139ae6a9e442468004a40f8
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 24, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

2026.9.24 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page