{"id":504,"date":"2015-07-22T07:49:27","date_gmt":"2015-07-22T12:49:27","guid":{"rendered":"http:\/\/bitquill.net\/blog\/?p=504"},"modified":"2016-05-12T14:26:53","modified_gmt":"2016-05-12T19:26:53","slug":"comparing-data-storage-options-in-python","status":"publish","type":"post","link":"https:\/\/bitquill.net\/blog\/comparing-data-storage-options-in-python\/","title":{"rendered":"Comparing data storage options in Python"},"content":{"rendered":"<p>When it comes to numerical computing, I always gave in to the unparalleled convenience of Matlab, which I think is the best IDE for that purpose. \u00c2\u00a0If your data consists of matrices or vectors\u00c2\u00a0and\u00c2\u00a0fits in main memory, it&#8217;s very hard to beat Matlab&#8217;s smooth workflow for interactive analysis and quick iteration. \u00c2\u00a0Also, with judicious use of MEX, performance is more than good enough. \u00c2\u00a0However, over the past two years, I&#8217;ve been increasingly using Python (with numpy, matplotlib, scipy, ipython, and scikit-learn), for three reasons: (i) I&#8217;m already a big Python fan; (ii) it&#8217;s open-source hence it&#8217;s easier for others to reuse your code; and (iii) more importantly, it can easily handle non-matrix data types (e.g., text, complex graphs) and has a large collection of libraries for almost anything you can imagine. \u00c2\u00a0In fact, even when using Matlab, I had a separate set of scripts to collect and\/or parse raw data, and then turn it into a matrix. \u00c2\u00a0Juggling both\u00c2\u00a0Python and Matlab code can get pretty messy, so why not do everything in Python?<\/p>\n<p>Before I continue, let me say that,\u00c2\u00a0yes, I know Matlab has cell arrays and even objects, but still&#8230; you wouldn&#8217;t really use Matlab for e.g., text processing or web scraping.\u00c2\u00a0Yes, I know Matlab has distributed computing toolboxes, but I&#8217;m only considering main memory here; these days 256GB RAM is not hard to come by and that&#8217;s good enough for 99% of (non-production) data exploration tasks. Finally, yes, I know you can interface Java to Matlab, but that&#8217;s still two languages and two codebases.<\/p>\n<p>Storing matrix data in Matlab is easy. \u00c2\u00a0The .MAT format works great, it is pretty efficient, and can be used with\u00c2\u00a0almost any language (including Python). \u00c2\u00a0At the other extreme, arbitrary objects can be stored in Python as pickles (the de-facto Python\u00c2\u00a0standard?), however (i) they are notoriously inefficient (even with cPickle), and (ii) they are not portable. \u00c2\u00a0I could perhaps live with (ii), but (i) is a problem. \u00c2\u00a0At some point, I tried out SqlAlchemy (on top of sqlite) which is quite feature-rich, but also quite inefficient, since it does a lot of things I don&#8217;t need. I had expected to pay a performance penalty, but hadn&#8217;t realized how large until measuring it. \u00c2\u00a0So, I decided to do some quick-n-dirty measurements of various options.<\/p>\n<p><!--more--><\/p>\n<p>The goal was to compare Python overheads (due to the interpreted nature of Python, the GIL, etc etc), not raw I\/O performance. \u00c2\u00a0Furthermore, I&#8217;m looking for a simple data storage solution, not for a distributed, fault-tolerant, scalable solution (so DHTs, column stores, etc like memcached, Riak, Redis, HBase, Cassandra, Impala, MongoDB, Neo4j, etc etc etc, are out). \u00c2\u00a0Also, I&#8217;m looking for something that&#8217;s as &#8220;Pythonic&#8221; as possible and with reasonably mature options (so I left things like LevelDB and Tokyo Cabinet out). \u00c2\u00a0And, in any case, <strong>this is not meant to be an exhaustive list (or a comprehensive benchmark, for that matter); I had to stop somewhere.<\/strong><\/p>\n<p>In the end, I ended up comparing\u00c2\u00a0the following storage options:<\/p>\n<ul>\n<li>sqlite: Load rows as tuples, directly using the sqlite DB-API interface.<\/li>\n<li>sqlite-obj:\u00c2\u00a0Similar, but also convert loaded tuples into Python objects (one member variable per column).<\/li>\n<li>sqlalchemy-core: Load rows as tuples, using SqlAlchemy core (on top of sqlite) instead of DB-API directly.<\/li>\n<li>sqlalchemy-orm: Use the full-blown ORM and session features from SqlAlchemy (on top of sqlite).<\/li>\n<li>csv: CSV files, manually parsed (file iteration and string split).<\/li>\n<li>csv-numpy: CSV files parsed using the I\/O functions in numpy.<\/li>\n<li>cpickle: Store records as tuples, using cPickle.<\/li>\n<li>cpickle-obj: Store records as Python objects (one member variable per column), using cPickle.<\/li>\n<li>zodb: ZOPE object store, using BTrees.<\/li>\n<li>pytables: A library for tabular data built on top of HDF5.<\/li>\n<li>h5py: Another, lower-level library for HDF5 files.<\/li>\n<\/ul>\n<p>Furthermore, I also wanted to get an idea of how easily Python code can be optimized. \u00c2\u00a0In the past, I&#8217;d hand-coded C extensions when really necessary, I had played a little bith with cython, and I had heard of PyPy (but never tried it). \u00c2\u00a0So, while at it, I also considered\u00c2\u00a0the following Python implementations and tools:<\/p>\n<ul>\n<li>cpython: \u00c2\u00a0The usual implementation of Python.<\/li>\n<li>cython: Python extension that allows compiling to C extensions; no substantial benefit compared to cpython (at least without investing major effort: I only annotated some of the loop variables, the rest of the code was unmodified), so results are omitted.<\/li>\n<li>pypy: Python JIT compiler; some libraries won&#8217;t run on this, so those are missing from the results.<\/li>\n<\/ul>\n<p>The dataset used was very simple, consisting of five columns\/fields of random floating point numbers (so the data are, hopefully, incompressible), with sizes of up to 500M records. \u00c2\u00a0The dataset size is quite modest, but should be sufficient for the goals stated above (comparing relative Python overheads, not actual disk I\/O performance). File sizes (relative to sqlite, again) are shown below. \u00c2\u00a0For the record, the &#8216;raw&#8217; data (500,000 rec x 5 floats\/rec x 8 bytes\/float) would have stood at 0.74, same as pytables which has zero overhead (well, 64KB to be exact); sqlite has a 36% overhead. \u00c2\u00a0ZODB size includes the index, but that&#8217;s just 2.7% of the total (caveat: although records were only added, never deleted, I&#8217;m not familiar with ZODB and didn&#8217;t check if I should still have done any manual garbage collection).<\/p>\n<p><a href=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/07\/filesizes.png\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" data-attachment-id=\"619\" data-permalink=\"https:\/\/bitquill.net\/blog\/comparing-data-storage-options-in-python\/filesizes\/\" data-orig-file=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/07\/filesizes.png?fit=650%2C244&amp;ssl=1\" data-orig-size=\"650,244\" data-comments-opened=\"1\" data-image-meta=\"{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}\" data-image-title=\"filesizes\" data-image-description=\"\" data-image-caption=\"\" data-large-file=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/07\/filesizes.png?fit=650%2C244&amp;ssl=1\" class=\"alignnone wp-image-619\" src=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/07\/filesizes.png?resize=460%2C173\" alt=\"filesizes\" width=\"460\" height=\"173\" srcset=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/07\/filesizes.png?w=650&amp;ssl=1 650w, https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/07\/filesizes.png?resize=300%2C113&amp;ssl=1 300w\" sizes=\"auto, (max-width: 460px) 100vw, 460px\" \/><\/a><\/p>\n<p>Runs were performed on a machine with an ext4-formatted Samsung EVO850 1TB SSD, Ubuntu 14.04LTS and, FWIW, a Core i7-480K at 3.7GHz. RAM was 64GB and, therefore, the buffercache was\u00c2\u00a0more than large enough to fit all dataset sizes. \u00c2\u00a0One run was used to warm up the cache, and results shown\u00c2\u00a0are from a second run. \u00c2\u00a0Note that, particularly in this setting (i.e., reading from memory), many (most?) of the libraries appear to be\u00c2\u00a0CPU-bound (due to serialization\/deserialization and object construction overheads), not I\/O-bound. \u00c2\u00a0I cautiously say &#8220;appear to be&#8221; since this statement is based on eyeballing &#8220;top&#8221; output, rather than any serious profiling.<\/p>\n<p>For full disclosure, here&#8217;s\u00c2\u00a0<a href=\"http:\/\/bitquill.net\/blog\/wp-content\/uploads\/2015\/06\/pybench.zip\">a dump of the\u00c2\u00a0source files and timing data<\/a>, provided as-is\u00c2\u00a0(so, caveat: far from release quality, not intended for reuse,\u00c2\u00a0really messy, undocumented, etc etc\u00e2\u20ac\u201dsome bits need to be run manually through an iPython prompt and\/or commented-in\/commented-out, don&#8217;t ask me which, I don&#8217;t remember :). If, however, anyone finds anything stupid there, please do let me know.<\/p>\n<p>First a sanity check, wall clock time vs. dataset size is perfectly linear, as expected:<\/p>\n<p><a href=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/wall-time-1.png\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" data-attachment-id=\"505\" data-permalink=\"https:\/\/bitquill.net\/blog\/comparing-data-storage-options-in-python\/wall-time-1\/\" data-orig-file=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/wall-time-1.png?fit=644%2C464&amp;ssl=1\" data-orig-size=\"644,464\" data-comments-opened=\"1\" data-image-meta=\"{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}\" data-image-title=\"wall-time-1\" data-image-description=\"\" data-image-caption=\"\" data-large-file=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/wall-time-1.png?fit=644%2C464&amp;ssl=1\" class=\"alignnone wp-image-505\" style=\"margin-left: 40px;\" src=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/wall-time-1.png?resize=450%2C324\" alt=\"Wall-clock time vs dataset size\" width=\"450\" height=\"324\" srcset=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/wall-time-1.png?w=644&amp;ssl=1 644w, https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/wall-time-1.png?resize=300%2C216&amp;ssl=1 300w\" sizes=\"auto, (max-width: 450px) 100vw, 450px\" \/><\/a><\/p>\n<p>The next plot shows average wall-clock time (over all dataset sizes) for both cpython and pypy, normalized against that of raw\u00c2\u00a0sqlite with cpython:<\/p>\n<p><a href=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/time-bar-1.png\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" data-attachment-id=\"510\" data-permalink=\"https:\/\/bitquill.net\/blog\/comparing-data-storage-options-in-python\/time-bar-1\/\" data-orig-file=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/time-bar-1.png?fit=691%2C404&amp;ssl=1\" data-orig-size=\"691,404\" data-comments-opened=\"1\" data-image-meta=\"{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}\" data-image-title=\"time-bar-1\" data-image-description=\"\" data-image-caption=\"\" data-large-file=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/time-bar-1.png?fit=691%2C404&amp;ssl=1\" class=\"alignnone wp-image-510\" src=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/time-bar-1.png?resize=480%2C281\" alt=\"time-bar-1\" width=\"480\" height=\"281\" srcset=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/time-bar-1.png?w=691&amp;ssl=1 691w, https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/time-bar-1.png?resize=300%2C175&amp;ssl=1 300w\" sizes=\"auto, (max-width: 480px) 100vw, 480px\" \/><\/a><\/p>\n<p>As usual, I procrastinated several weeks before posting any of this. In the meantime, I added a second EVO850 and migrated from ext4 to\u00c2\u00a0btrfs with RAID-0 for data and LZO compression. \u00c2\u00a0Out of curiosity I reran the code. \u00c2\u00a0While at it, I added ZODB to the mix. Here are the results (cpython only, normalized against sqlite on btrfs):<\/p>\n<p><a href=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/time-bar-2.png\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" data-attachment-id=\"515\" data-permalink=\"https:\/\/bitquill.net\/blog\/comparing-data-storage-options-in-python\/time-bar-2\/\" data-orig-file=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/time-bar-2.png?fit=718%2C404&amp;ssl=1\" data-orig-size=\"718,404\" data-comments-opened=\"1\" data-image-meta=\"{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}\" data-image-title=\"time-bar-2\" data-image-description=\"\" data-image-caption=\"\" data-large-file=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/time-bar-2.png?fit=718%2C404&amp;ssl=1\" class=\"alignnone wp-image-515\" src=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/time-bar-2.png?resize=480%2C270\" alt=\"time-bar-2\" width=\"480\" height=\"270\" srcset=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/time-bar-2.png?w=718&amp;ssl=1 718w, https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/time-bar-2.png?resize=300%2C169&amp;ssl=1 300w\" sizes=\"auto, (max-width: 480px) 100vw, 480px\" \/><\/a><\/p>\n<p>Pytables is, oddly, faster! \u00c2\u00a0For completeness, here are the speedups observed with striping across two disks, vs a single disk.<\/p>\n<p><a href=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/raid-speedup.png\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" data-attachment-id=\"516\" data-permalink=\"https:\/\/bitquill.net\/blog\/comparing-data-storage-options-in-python\/raid-speedup\/\" data-orig-file=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/raid-speedup.png?fit=688%2C364&amp;ssl=1\" data-orig-size=\"688,364\" data-comments-opened=\"1\" data-image-meta=\"{&quot;aperture&quot;:&quot;0&quot;,&quot;credit&quot;:&quot;&quot;,&quot;camera&quot;:&quot;&quot;,&quot;caption&quot;:&quot;&quot;,&quot;created_timestamp&quot;:&quot;0&quot;,&quot;copyright&quot;:&quot;&quot;,&quot;focal_length&quot;:&quot;0&quot;,&quot;iso&quot;:&quot;0&quot;,&quot;shutter_speed&quot;:&quot;0&quot;,&quot;title&quot;:&quot;&quot;,&quot;orientation&quot;:&quot;0&quot;}\" data-image-title=\"raid-speedup\" data-image-description=\"\" data-image-caption=\"\" data-large-file=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/raid-speedup.png?fit=688%2C364&amp;ssl=1\" class=\"alignnone wp-image-516\" src=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/raid-speedup.png?resize=460%2C243\" alt=\"raid-speedup\" width=\"460\" height=\"243\" srcset=\"https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/raid-speedup.png?w=688&amp;ssl=1 688w, https:\/\/i0.wp.com\/bitquill.net\/blog\/wp-content\/uploads\/2015\/05\/raid-speedup.png?resize=300%2C159&amp;ssl=1 300w\" sizes=\"auto, (max-width: 460px) 100vw, 460px\" \/><\/a><\/p>\n<p>Remember that these are (or should be) hot buffercache measurements, so disk I\/O bandwidth should not matter, only memory bandwidth. \u00c2\u00a0Not quite sure what is going on here; I don&#8217;t believe PyTables uses multiple threads\u00c2\u00a0in its C code\u00c2\u00a0(and, even if it did, why would the number of threads depend on the&#8230; RAID level??). \u00c2\u00a0Maybe some profiling is in order (and, if you have any ideas, please let me know).<\/p>\n<p><strong>Comparing Python implementations.<\/strong>\u00c2\u00a0Woah, look at PyPy go! \u00c2\u00a0When it works, it really works. \u00c2\u00a0See\u00c2\u00a0SqlAlchemy go from being 2.5x slower when using the low-level APIs or 25x slower with all the heavyweight ORM machinery, to almost directly competitive with raw sqlite or 6x slower (a 4x speedup), respectively. \u00c2\u00a0Similarly, manual object re-construction on top of raw sqlite now has negligible overhead. \u00c2\u00a0However, most libraries unfortunately do not (yet) run on PyPy. \u00c2\u00a0More importantly, the frameworks I need for data analysis also do not support PyPy (I&#8217;m aware there is a special version of NumPy, but matplotlib, SciPy, etc are\u00c2\u00a0still far from being compatible). \u00c2\u00a0Also, I&#8217;m not quite sure why pickles were noticeably slower with PyPy.<\/p>\n<p><strong>Comparing data formats.<\/strong> Sqlite is overall the most competitive option. \u00c2\u00a0This is good; you can never really go wrong with a relational data format, so it should serve as a general\u00c2\u00a0basis. PyTables is also impressively fast (it&#8217;s pretty much the only option that beats raw sqlite, for this simple table of all-floats). \u00c2\u00a0Finally, I was somewhat surprised that NumPy&#8217;s CSV I\/O is that slow (yes, it has to deal with all the funky quotation, escapes, and formatting variations, and CSV text is not exactly a high-performance format, but still&#8230;).<\/p>\n<p>For the time being, I&#8217;ll probably stick with sqlite, but get rid of the SqlAlchemy ORM bits that I&#8217;ve been using (or, perhaps, keep them for small datasets). The nice thing is that I can keep my data files and perhaps look for a better abstraction layer than DB-API, but the sqlite &#8220;core&#8221; itself appears reasonably efficient. Eventually, however, I&#8217;d like to have something like the relationship feature of the ORM (but without all the other heavyweight machinery for sessions, syncing, etc), so I can easily persist graph data, with arbitrary node and edge attributes (FWIW, I currently\u00c2\u00a0use NetworkX once the data is loaded; I know it&#8217;s slow, but it&#8217;s very Pythonic and convenient, and I rarely resort to iGraph or other libraries, at least so far\u00e2\u20ac\u201dbut that&#8217;s another story).<\/p>\n","protected":false},"excerpt":{"rendered":"<p>When it comes to numerical computing, I always gave in to the unparalleled convenience of Matlab, which I think is the best IDE for that purpose. \u00c2\u00a0If your data consists of matrices or vectors\u00c2\u00a0and\u00c2\u00a0fits in main memory, it&#8217;s very hard to beat Matlab&#8217;s smooth workflow for interactive analysis and quick iteration. \u00c2\u00a0Also, with judicious use [&hellip;]<\/p>\n","protected":false},"author":2,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_monsterinsights_skip_tracking":false,"_monsterinsights_sitenote_active":false,"_monsterinsights_sitenote_note":"","_monsterinsights_sitenote_category":0,"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_publicize_message":"","jetpack_publicize_feature_enabled":true,"jetpack_social_post_already_shared":false,"jetpack_social_options":{"image_generator_settings":{"template":"highway","default_image_id":0,"font":"","enabled":false},"version":2},"jetpack_post_was_ever_published":false},"categories":[45],"tags":[76],"class_list":["post-504","post","type-post","status-publish","format-standard","hentry","category-scitech","tag-python"],"jetpack_publicize_connections":[],"jetpack_featured_media_url":"","jetpack_shortlink":"https:\/\/wp.me\/p7x9xm-88","jetpack-related-posts":[],"jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/bitquill.net\/blog\/wp-json\/wp\/v2\/posts\/504","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/bitquill.net\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/bitquill.net\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/bitquill.net\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/bitquill.net\/blog\/wp-json\/wp\/v2\/comments?post=504"}],"version-history":[{"count":32,"href":"https:\/\/bitquill.net\/blog\/wp-json\/wp\/v2\/posts\/504\/revisions"}],"predecessor-version":[{"id":695,"href":"https:\/\/bitquill.net\/blog\/wp-json\/wp\/v2\/posts\/504\/revisions\/695"}],"wp:attachment":[{"href":"https:\/\/bitquill.net\/blog\/wp-json\/wp\/v2\/media?parent=504"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/bitquill.net\/blog\/wp-json\/wp\/v2\/categories?post=504"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/bitquill.net\/blog\/wp-json\/wp\/v2\/tags?post=504"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}