bitcoin-core-rpc
A standalone JSON-RPC client against Bitcoin Core. As used by the btclib library.
One source file with 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.
Install it, or copy the file — Vendoring below is how, and it is a supported way to use this rather than a fallback.
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.
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.
When it goes wrong
Three exceptions, because there are three different things to do about
them. All three are 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
- batches. One call is one HTTP request. A batch needs an api for
correlating the answers and for partly failing, which is a question of
its own; a loop over
callis the replacement, an equivalent beside the node and not over a link where the round trip costs something. - notifications, a request sent with no
id, which a node does not answer. - retries, per above.
- redirects. A 30x arrives as an
HttpErrorrather than as a second request: the first one already carries theAuthorizationfor the host it names, and following the redirect would send that credential wherever theLocationpoints. - proxies from the environment.
HTTP_PROXYis 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 atransport.
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. What a
caller rewrites is below, an AuthServiceProxy line at a time with the
same command under it.
Connecting. The credential is an argument, and a url carrying
user:password@ is refused rather than accepted and stripped: that url is
the string that ends up in a configuration file, a traceback and a log.
# 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
)
A node left on its defaults needs neither argument: from_chain has the
port and the cookie file, per Talking to a node.
Invoking a method. The method is an argument and not an attribute: any
name a node has works without this class knowing it, and none of them can
collide with a name of the client's own. params is then one argument as
well, a sequence for the positional form and a mapping for the named one.
# AuthServiceProxy
block = rpc.getblock(block_id, 2)
# this client
block = client.call("getblock", [block_id, 2])
block = client.call("getblock", {"blockhash": block_id, "verbosity": 2})
A wallet command. /wallet/<name> is derived from the client rather
than written into a second url, and the name is percent-encoded.
# AuthServiceProxy
hot = AuthServiceProxy(f"http://{user}:{password}@127.0.0.1:8332/wallet/hot")
balance = hot.getbalance()
# this client
balance = client.for_wallet("hot").call("getbalance")
A batch. There is none, per What it does not do, and a loop is what replaces it: the calls go one HTTP request each rather than several in one, and each answer is a value or an exception where a batch answered with a list to inspect.
# AuthServiceProxy
hashes = rpc.batch_([["getblockhash", height] for height in heights])
# this client
hashes = [client.call("getblockhash", [height]) for height in heights]
An error. JSONRPCException becomes three exceptions, and When it
goes wrong above has the table: RpcError for an
error the node computed, HttpError for an exchange that failed and
FetchError for no answer to read. All three are FetchError, so except FetchError is the translation that catches what the one exception caught.
COMPARISON.md has the case for the switch beyond this rewrite guide, 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 the source is bitcoin_core_rpc/__init__.py and not a
top-level module: PEP 561 puts it inside a package directory and nowhere
else. pyproject.toml records what the alternatives were measured to do.
Vendoring
Copy bitcoin_core_rpc/__init__.py whole from a release tag, rename it
to bitcoin_core_rpc.py, keep the license notice at the top of it — MIT,
embedded rather than referenced, because a copy has no LICENSE beside it
— and record the tag next to the copy. An update is a replacement of the
whole file, and this shows every behavioral change first:
git diff OLD..NEW -- bitcoin_core_rpc/__init__.py
A vendored copy receives no security or compatibility fix automatically, so its recorded tag is what says whether it needs replacing. An installed one is a version an ordinary dependency bump moves, which is why installing is the default advice.
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.
Links
- Documentation: https://bitcoin-core-rpc.readthedocs.io/
- Source: https://github.com/btclib-org/bitcoin-core-rpc
- Releases: https://github.com/btclib-org/bitcoin-core-rpc/releases
- CHANGELOG.md, and HISTORY.md for what a release asks a user to act on
Release files for bitcoin-core-rpc 2026.8.13
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| bitcoin_core_rpc-2026.8.13.tar.gz | 368.3 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| bitcoin_core_rpc-2026.8.13-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 412.7 kB
Release files / bitcoin_core_rpc-2026.8.13.tar.gz
| Download URL | bitcoin_core_rpc-2026.8.13.tar.gz |
|---|---|
| Size | 368.3 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ab7c748457756982561a07bf699eda211fcfe99acbe6ef46cf4d43b311025d03
|
|
BLAKE2b-256 checksum How to use checksums |
94f8bc602214490d47ed66f857a119e66d8c2788691d597024536d4c702a380f
|
| 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 Aug 13, 2026.
Transparency logRelease files / bitcoin_core_rpc-2026.8.13-py3-none-any.whl
| Download URL | bitcoin_core_rpc-2026.8.13-py3-none-any.whl |
|---|---|
| Size | 44.5 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
606285cc836780d55ac426d573dfe051c5fa9f0300b73e9cf4da695ea0215456
|
|
BLAKE2b-256 checksum How to use checksums |
87e9ae5634c2c8ffd36c5013412e0450cff63db7138ba3c3a829a9f6a98d4e3e
|
| 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 Aug 13, 2026.
Transparency log