53

Inspired by this question, perhaps we could collect together a bunch of scripts to convert the XML data-dump files into other formats, such as SQLite, MySQL and the likes.

To keep things consistent, some guidelines:

If something changes in the data-dump, and the old script no longer works, make a new post for the updated script.

Don't depend on the machine having lots of memory, reading the entire set of XML files into memory is probably going to cause a lot of machines to run out of memory - some of these questions should help with this..

Mention version numbers of things you are using, well, relevant things, like the programming language and any libraries. Linking to non-standard modules and such would be nice too

Use the following template for all posted scripts:

# {New format name}

{Implementation language}

Tested and working on release(s):

- {List of releases the script is known to work on}

----

    {code}

For example:

SQLite3

Python 2.6.4

Tested and working on release(s):

  • Oct 2009
  • Nov 2009

import sqlite3
import magic
magic.convert("*.xml")
3
  • It would be pretty nice to have a way to render the dumps the same way the SE sites are normally rendered in the browser.
    – DanielSank
    Jun 17, 2015 at 1:16
  • Was the point of the question to have those scripts here or in a repository some place? Oct 31, 2016 at 16:13
  • I can't get any of the python scripts to work and the C++ fails with "unknown column in PostHistory: ContentLicense". Anyone willing to create a version in PHP or Perl? Or possibly fix the C++ version? Sep 29, 2022 at 18:31

10 Answers 10

13

Python script to import/create SQLite3 database from SO data dump

Requires:

  • Python 2.5+
  • lxml
import sqlite3
import os
import xml.etree.cElementTree as etree
import logging

ANATHOMY = {
 'badges': {
  'Id':'INTEGER',
  'UserId':'INTEGER',
  'Name':'TEXT',
  'Date':'DATETIME',
 },
 'comments': {
  'Id':'INTEGER',
  'PostId':'INTEGER',
  'Score':'INTEGER',
  'Text':'TEXT',
  'CreationDate':'DATETIME',
  'UserId':'INTEGER',
 },
 'posts': {
  'Id':'INTEGER', 
  'PostTypeId':'INTEGER', # 1: Question, 2: Answer
  'ParentID':'INTEGER', # (only present if PostTypeId is 2)
  'AcceptedAnswerId':'INTEGER', # (only present if PostTypeId is 1)
  'CreationDate':'DATETIME',
  'Score':'INTEGER',
  'ViewCount':'INTEGER',
  'Body':'TEXT',
  'OwnerUserId':'INTEGER', # (present only if user has not been deleted) 
  'LastEditorUserId':'INTEGER',
  'LastEditorDisplayName':'TEXT', #="Rich B" 
  'LastEditDate':'DATETIME', #="2009-03-05T22:28:34.823" 
  'LastActivityDate':'DATETIME', #="2009-03-11T12:51:01.480" 
  'CommunityOwnedDate':'DATETIME', #(present only if post is community wikied)
  'Title':'TEXT',
  'Tags':'TEXT',
  'AnswerCount':'INTEGER',
  'CommentCount':'INTEGER',
  'FavoriteCount':'INTEGER',
  'ClosedDate':'DATETIME',
 },
 'votes': {
  'Id':'INTEGER',
  'PostId':'INTEGER',
  'UserId':'INTEGER',
  'VoteTypeId':'INTEGER',
           # -   1: AcceptedByOriginator
           # -   2: UpMod
           # -   3: DownMod
           # -   4: Offensive
           # -   5: Favorite
           # -   6: Close
           # -   7: Reopen
           # -   8: BountyStart
           # -   9: BountyClose
           # -  10: Deletion
           # -  11: Undeletion
           # -  12: Spam
           # -  13: InformModerator
  'CreationDate':'DATETIME',
  'BountyAmount':'INTEGER'
 },
 'users': {
  'Id':'INTEGER',
  'Reputation':'INTEGER',
  'CreationDate':'DATETIME',
  'DisplayName':'TEXT',
  'LastAccessDate':'DATETIME',
  'WebsiteUrl':'TEXT',
  'Location':'TEXT',
  'Age':'INTEGER',
  'AboutMe':'TEXT',
  'Views':'INTEGER',
  'UpVotes':'INTEGER',
  'DownVotes':'INTEGER',
  'EmailHash':'TEXT'
  },
}

def dump_files(file_names, anathomy, 
    dump_path='c:\\temp\\', 
    dump_database_name = 'so-dump.db',
    create_query='CREATE TABLE IF NOT EXISTS [{table}]({fields})',
    insert_query='INSERT INTO {table} ({columns}) VALUES ({values})',
    log_filename='so-parser.log'):

 logging.basicConfig(filename=os.path.join(dump_path, log_filename),level=logging.INFO)
 db = sqlite3.connect(os.path.join(dump_path, dump_database_name))

 for file in file_names:
  print "Opening {0}.xml".format(file)
  with open(os.path.join(dump_path, file + '.xml')) as xml_file:
   tree = etree.iterparse(xml_file)
   table_name = file

   sql_create = create_query.format(
        table=table_name, 
        fields=", ".join(['{0} {1}'.format(name, type) for name, type in anathomy[table_name].items()]))
   print('Creating table {0}'.format(table_name))

   try:
    logging.info(sql_create)
    db.execute(sql_create)
   except Exception, e:
    logging.warning(e)

   for events, row in tree:
    try:
     logging.debug(row.attrib.keys())

     db.execute(insert_query.format(
        table=table_name, 
        columns=', '.join(row.attrib.keys()), 
        values=('?, ' * len(row.attrib.keys()))[:-2]),
        row.attrib.values())
     print ".",
    except Exception, e:
     logging.warning(e)
     print "x",
    finally:
     row.clear()
   print "\n"
   db.commit()
   del(tree)
   
if __name__ == '__main__':
 dump_files(ANATHOMY.keys(), ANATHOMY)
3
  • Could someone with edit permission please update the ANATHOMY declaration in the script above with the following: - Under 'votes', add 'BountyAmount':'INTEGER', - Under 'users', change 'DisplayName':'DATETIME' to 'DisplayName':'TEXT' - Under 'users', add 'EmailHash':'TEXT' - Under 'badges', change 'name' to 'Name' and 'date' to 'Date' Not sure if the badges change affects anything, but the others definitely affect what ends up in the database. Aug 27, 2010 at 12:58
  • Nearly one year later, I did so. :) On Linux, change dump_path='c:\\temp\\' to something else - I would suggest "." for all systems. Executing it, I get an error about missing 'vote.xml' - guess I have to find it somewhere. :) Jul 29, 2011 at 3:33
  • 3
    I slightly improved this code. It's on github. Now it works well with 2014.05 stackoverflow dump.
    – testlnord
    Aug 6, 2014 at 14:10
6

C++ (SQLite3, PostgreSQL)

Columns containing numeric values are converted to numbers, columns containing dates are converted to Unix timestamps and stored as numbers. Optionally indexes are added for all numeric columns. The import takes about 5-10 minutes for the pure data and about 10 minutes more if generating indexes.

Usage

To generate a SQLite database, start the sqliteimport program in a directory containing the data dumps XML files and it creates a dump.db in the same directory. If you don't want indexes (creating them takes some time), start the program with sqliteimport -I.

PostgreSQL support is still a little bit experimental. To generate a PostgreSQL database, start the pgcopyimport program in a directory containing the data dumps XML files. Pass a -c flag with the necessary database information, like pgcopyimport -c "dbname=sodump user=so password=abc". The database needs to exist. Warning: This program creates big temporary files in /var/tmp, about as big as the single XML files. This is done to quickly load the data into the database with COPY FROM. The the temporary files are named badges.pgdata,...

If you don't want to create temporary files use the pgimport program instead, but this will be a lot slower. PostgreSQL import also skips the generation of indexes if the -I flag is added.

Other uses

The design should also be fairly flexible if you want to extract special data from the XML files, parse different XML files, or create something else than a SQLite/PostgreSQL database from the input.

3
  • The "source" link now returns a 404 Feb 16, 2021 at 3:32
  • @SedateAlien I fixed the link
    – sth
    Feb 16, 2021 at 19:10
  • Currently reporting: "unknown column in PostHistory: ContentLicense" Sep 29, 2022 at 18:23
5

If you are importing into MySQL, you might want to use the following MySQL script:

https://gist.github.com/gousiosg/7600626

No extra dependencies required

1
  • I just used this, it was a little clunky but totally handled its business
    – Kristian
    Jan 22, 2015 at 18:48
4

I have a short C# program to help import the Posts table into SQL Server. You can see an old version here:
Scripts to convert data-dump to other formats.

If I remember and have time at home later I'll post updated code. The other tables were imported via Brent Ozar's instructions (with some modifications for schema changes):
http://www.brentozar.com/archive/2009/06/how-to-import-the-stackoverflow-xml-into-sql-server/.

4

So-Slow, is by far the fastest way to get data into SQL Server. It is still able to process current dumps in under 10 minutes, including tag splitting.

4

Python 3

This is the same exact script as here, except updated for Python 3 and the September 2016 data dump.

import sqlite3
import os
import xml.etree.cElementTree as etree
import logging

ANATHOMY = {
  'badges': {
    'Id': 'INTEGER',
    'UserId': 'INTEGER',
    'Class': 'INTEGER',
    'Name': 'TEXT',
    'Date': 'DATETIME',
    'TagBased': 'BOOLEAN',
  },
  'comments': {
    'Id': 'INTEGER',
    'PostId': 'INTEGER',
    'Score': 'INTEGER',
    'Text': 'TEXT',
    'CreationDate': 'DATETIME',
    'UserId': 'INTEGER',
    'UserDisplayName': 'TEXT'
  },
  'posts': {
      'Id': 'INTEGER',
      'PostTypeId': 'INTEGER',  # 1: Question, 2: Answer
      'ParentId': 'INTEGER',  # (only present if PostTypeId is 2)
      'AcceptedAnswerId': 'INTEGER',  # (only present if PostTypeId is 1)
      'CreationDate': 'DATETIME',
      'Score': 'INTEGER',
      'ViewCount': 'INTEGER',
      'Body': 'TEXT',
      'OwnerUserId': 'INTEGER',  # (present only if user has not been deleted)
      'OwnerDisplayName': 'TEXT',
      'LastEditorUserId': 'INTEGER',
      'LastEditorDisplayName': 'TEXT',  # ="Rich B"
      'LastEditDate': 'DATETIME',  #="2009-03-05T22:28:34.823"
      'LastActivityDate': 'DATETIME',  #="2009-03-11T12:51:01.480"
      'CommunityOwnedDate': 'DATETIME',  #(present only if post is community wikied)
      'Title': 'TEXT',
      'Tags': 'TEXT',
      'AnswerCount': 'INTEGER',
      'CommentCount': 'INTEGER',
      'FavoriteCount': 'INTEGER',
      'ClosedDate': 'DATETIME',
      'ContentLicense': 'TEXT'
  },
  'votes': {
      'Id': 'INTEGER',
      'PostId': 'INTEGER',
      'UserId': 'INTEGER',
      'VoteTypeId': 'INTEGER',
      # -   1: AcceptedByOriginator
      # -   2: UpMod
      # -   3: DownMod
      # -   4: Offensive
      # -   5: Favorite
      # -   6: Close
      # -   7: Reopen
      # -   8: BountyStart
      # -   9: BountyClose
      # -  10: Deletion
      # -  11: Undeletion
      # -  12: Spam
      # -  13: InformModerator
      'CreationDate': 'DATETIME',
      'BountyAmount': 'INTEGER'
  },
  'posthistory': {
      'Id': 'INTEGER',
      'PostHistoryTypeId': 'INTEGER',
      'PostId': 'INTEGER',
      'RevisionGUID': 'TEXT',
      'CreationDate': 'DATETIME',
      'UserId': 'INTEGER',
      'UserDisplayName': 'TEXT',
      'Comment': 'TEXT',
      'Text': 'TEXT'
  },
  'postlinks': {
      'Id': 'INTEGER',
      'CreationDate': 'DATETIME',
      'PostId': 'INTEGER',
      'RelatedPostId': 'INTEGER',
      'PostLinkTypeId': 'INTEGER',
      'LinkTypeId': 'INTEGER'
  },
  'users': {
      'Id': 'INTEGER',
      'Reputation': 'INTEGER',
      'CreationDate': 'DATETIME',
      'DisplayName': 'TEXT',
      'LastAccessDate': 'DATETIME',
      'WebsiteUrl': 'TEXT',
      'Location': 'TEXT',
      'Age': 'INTEGER',
      'AboutMe': 'TEXT',
      'Views': 'INTEGER',
      'UpVotes': 'INTEGER',
      'DownVotes': 'INTEGER',
      'AccountId': 'INTEGER',
      'ProfileImageUrl': 'TEXT'
  },
  'tags': {
      'Id': 'INTEGER',
      'TagName': 'TEXT',
      'Count': 'INTEGER',
      'ExcerptPostId': 'INTEGER',
      'WikiPostId': 'INTEGER'
  }
}


def dump_files(file_names, anathomy,
             dump_path='.',
             dump_database_name='so-dump.db',
             create_query='CREATE TABLE IF NOT EXISTS {table} ({fields})',
             insert_query='INSERT INTO {table} ({columns}) VALUES ({values})',
             log_filename='so-parser.log'):
  logging.basicConfig(filename=os.path.join(dump_path, log_filename), level=logging.INFO)
  db = sqlite3.connect(os.path.join(dump_path, dump_database_name))
  for file in file_names:
      print("Opening {0}.xml".format(file))
      with open(os.path.join(dump_path, file + '.xml')) as xml_file:
          tree = etree.iterparse(xml_file)
          table_name = file.lower()

          sql_create = create_query.format(
              table=table_name,
              fields=", ".join(['{0} {1}'.format(name, type) for name, type in anathomy[table_name].items()]))
          print('Creating table {0}'.format(table_name))

          try:
              logging.info(sql_create)
              db.execute(sql_create)
          except Exception as e:
              logging.warning(e)

          count = 0
          for events, row in tree:
              try:
                  if row.attrib.values():
                      logging.debug(row.attrib.keys())
                      query = insert_query.format(
                          table=table_name,
                          columns=', '.join(row.attrib.keys()),
                          values=('?, ' * len(row.attrib.keys()))[:-2])
                      vals = []
                      for key, val in row.attrib.items():
                          if anathomy[table_name][key] == 'INTEGER':
                              vals.append(int(val))
                          elif anathomy[table_name][key] == 'BOOLEAN':
                              vals.append(1 if val=="TRUE" else 0)
                          else:
                              vals.append(val)
                      db.execute(query, vals)

                      count += 1
                      if (count % 1000 == 0):
                          print("{}".format(count))

              except Exception as e:
                  logging.warning(e)
                  print("x", end="")
              finally:
                  row.clear()
          print("\n")
          db.commit()
          del (tree)


if __name__ == '__main__':
  dump_files(ANATHOMY.keys(), ANATHOMY)
6
  • 3
    I wouldn't usually post such a similar answer, but it took me an hour or so to make this work after starting from the other Python script; so, I thought I'd save people some time.
    – apnorton
    Oct 31, 2016 at 15:27
  • For the latest data dump (December 2017) line 125 should be table_name = file.lower()
    – user330457
    Jan 3, 2018 at 4:58
  • 1
    @ayhan I've just updated the code without checking to see if that works; I'll trust you're right. :)
    – apnorton
    Jan 4, 2018 at 23:57
  • I get FileNotFoundError: [Errno 2] No such file or directory: './badges.xml' on Linux. For case-sensitive file systems you need to run something like find . -iname '*.xml' -exec rename 's/(.*)\/([^\/]*)/$1\/\L$2/' {} \; to rename all xml files to lowercase before running the script.
    – rjh
    Mar 20, 2018 at 10:23
  • After that, I get UnicodeDecodeError: 'utf-8' codec can't decode bytes in position 2005-2006: invalid continuation byte
    – rjh
    Mar 20, 2018 at 10:23
  • This worked perfectly for me on macOS High Sierra using Python 3.6. Jul 8, 2018 at 14:24
2

MS SQL Server 2000/05/08 - MySQL 5.1 - SQLite3

C#

Tested and working on release(s):

  • March 2010 - all sites, any way you want it. 5 to 17 minutes depending on options

Here is a Windows application that will import to MySQL, MS SQL Server and SQLite:

https://meta.stackexchange.com/questions/45333/fast-multi-platform-data-dump-import-sql-2000-05-08-sqlite-mysql

2

.xml data dump files to R data frame

I think that this code could be (somewhat) useful if someone need data in R but for some reason want to read this .xml data directly. But remember that R is still R and (if dataset will be to big for resources you have) could crash due to lack of RAM.

require(XML)
require(plyr)

xmlDataDumpToDataFrame<- function(xmlFilePath){

  doc <- xmlParse(xmlFilePath)  
  xmlList<- xmlToList(doc)

  total<-length(xmlList)

  print(paste0("Parsing file : ", xmlFilePath ))
  progressBar <- txtProgressBar(min = 0, max = total, style = 3)

  data<-data.frame()

  for(i in 1: total){
    data <- rbind.fill(data,as.data.frame(as.list( xmlList[[i]])))
    setTxtProgressBar(progressBar, i)
  }
  close(progressBar)

  print(paste0("created object size : ", format(object.size(data), units= "Mb") ))

  return(data)
}

Usage example:

usersData <- xmlDataDumpToDataFrame("stats.stackexchange.com/Users.xml")

As you can noticed, I also added a progress bar and an information how big created data frame is.

0

CSV converter

Implemented in Pyspark. This works in parallel, utilizing Apache Spark's RDDs and complete the conversion in 2-3 minutes for 1GB xml file with a minimal 2-core and 2 GB RAM Spark setup.

Tested and working on release(s):

  • March 2016 - all sites, any way you want it.
  • Should be working on all previous versions also

Github repo containing the code - XmltoCsv_StackExchange

5
  • 2
    The Spark requirement is pesky if you don't have Spark and the ad-hoc function to slurp in a line at a time looks rather suspicious. I created a simple proof of concept for exporting posts to CSV using Python cElementTree which avoids reading the entire tree into memory, and which uses 52 seconds per gigabyte in my quick informal test (maybe the parallelism isn't really useful here?) I'ld appreciate any feedback: gist.github.com/tripleee/3ae578463d3c8b2f52f99b78cd31bbf7
    – tripleee
    Aug 11, 2017 at 7:54
  • @tripleee You did a good job. But there is a problem with the result of your script. CSV does not support new line inside cell value. But almost all the Posts body contain new line, so the CSV is broken. You should add a replace() after reading the cell to remove the \r and \n.
    – J.M. Kenny
    Jul 28, 2020 at 19:45
  • @J.M.Kenny Thanks for the feedback. I had forgotten about this. But as a matter of fact, CSV does support newlines inside fields, as long as those fields are quoted. The Python csv library should take care of this. Are you using something less savvy to read them?
    – tripleee
    Jul 29, 2020 at 4:26
  • Perhaps you have this? stackoverflow.com/questions/1241220/… (As if there weren't enough reasons to avoid Excel already!)
    – tripleee
    Jul 29, 2020 at 4:34
  • Ok. I used the generated import to upload the CSV into a database, but it looks like their CSV reader is broken then. I ended up removing the newlines since they are not necessary for my purpose. Thank you.
    – J.M. Kenny
    Jul 30, 2020 at 13:03
0

R script using purrr and xml2

library(dplyr)
library(xml2)
library(purrr)

# function taking path and returning data frame
xml_to_frame<-function(path){
  data <- read_xml(path) %>%
    xml_find_all("//row") %>% 
    map(xml_attrs) %>% 
    map_df(~as.list(.))
  return(data)
}

# path to frame
votes<-xml_to_frame("votes.xml")

# frame to CSV
votes %>% fwrite("votes.csv")

This works a lot faster than most other R implementations as it avoids looping over the rows. This does read the entire XML into memory but it's still useful for most smaller sites. You can probably adapt xml_find_all() to read blocks of rows (e.g. read a 40GB file in three chunks) but I haven't found (or needed) that option yet.

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .